From 110c06229e70c8cfc5d1f3ab8f8f621236f98c13 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 29 Jul 2026 21:14:27 +0900 Subject: [PATCH 1/2] fix(line): normalize full-width code digits + webhook diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese keyboards produce full-width digits (123456), which the ASCII-only code matcher rejected — those users fell through to the account-link button and its external-browser failure. Codes are now normalized (full-width → ASCII, whitespace stripped) before matching. Also: bindProfile verifies a profiles row was actually updated (0-row update now sends the link-failed DM instead of a false confirmation), and webhook events / code attempts / bind outcomes are logged with masked LINE ids for Vercel-log diagnosis. Co-Authored-By: Claude Fable 5 --- src/v1/line/line.service.spec.ts | 63 +++++++++++++++++++++++++++++++- src/v1/line/line.service.ts | 52 ++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/v1/line/line.service.spec.ts b/src/v1/line/line.service.spec.ts index b9d8a16..da00ecd 100644 --- a/src/v1/line/line.service.spec.ts +++ b/src/v1/line/line.service.spec.ts @@ -185,7 +185,7 @@ describe('LineService', () => { data: { nonce: 'nonce-1', user_id: 'user-1', expires_at: 'later' }, error: null, }); - const profileUpdate = chain({ data: null, error: null }); + const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null }); const nonceDelete = chain({ data: null, error: null }); const adminClient = buildAdminClient({ cw_line_link_nonces: [nonceLookup, nonceDelete], @@ -292,7 +292,7 @@ describe('LineService', () => { it('links the sender when an unbound user sends a valid 6-digit code', async () => { const isLinkedLookup = chain({ data: null, error: null }); - const profileUpdate = chain({ data: null, error: null }); + const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null }); const codeLookup = chain({ data: { nonce: '123456', user_id: 'user-1', expires_at: 'later' }, error: null, @@ -325,6 +325,65 @@ describe('LineService', () => { expect(String(messages[0].text)).toContain('連携が完了'); }); + it('accepts full-width digits and surrounding whitespace in codes', async () => { + const isLinkedLookup = chain({ data: null, error: null }); + const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null }); + const codeLookup = chain({ + data: { nonce: '123456', user_id: 'user-1', expires_at: 'later' }, + error: null, + }); + const codeDelete = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + profiles: [isLinkedLookup, profileUpdate], + cw_line_link_nonces: [codeLookup, codeDelete], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { + type: 'message', + source: { userId: LINE_USER }, + message: { type: 'text', text: ' 123456 ' }, + }, + ]); + + // Lookup must use the normalized ASCII code. + expect(codeLookup.calls).toContainEqual({ + method: 'eq', + args: ['nonce', '123456'], + }); + expect(profileUpdate.calls).toContainEqual({ + method: 'update', + args: [{ line_id: LINE_USER }], + }); + expect(apiClient.issueLinkToken).not.toHaveBeenCalled(); + }); + + it('replies link-failed when no profiles row matches the code owner', async () => { + const isLinkedLookup = chain({ data: null, error: null }); + const profileUpdate = chain({ data: [], error: null }); + const codeLookup = chain({ + data: { nonce: '123456', user_id: 'ghost-user', expires_at: 'later' }, + error: null, + }); + const adminClient = buildAdminClient({ + profiles: [isLinkedLookup, profileUpdate], + cw_line_link_nonces: [codeLookup], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { + type: 'message', + source: { userId: LINE_USER }, + message: { type: 'text', text: '123456' }, + }, + ]); + + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('連携に失敗'); + }); + it('replies invalid-code when the 6-digit code is unknown or expired', async () => { const adminClient = buildAdminClient({ profiles: [chain({ data: null, error: null })], diff --git a/src/v1/line/line.service.ts b/src/v1/line/line.service.ts index a5a0f4f..caab292 100644 --- a/src/v1/line/line.service.ts +++ b/src/v1/line/line.service.ts @@ -82,6 +82,9 @@ export class LineService { async handleEvents(events: LineWebhookEvent[]): Promise { for (const event of events) { try { + this.logger.log( + `LINE webhook event: ${event?.type ?? 'unknown'} from ${maskId(event?.source?.userId ?? 'unknown')}`, + ); await this.handleEvent(event); } catch (error) { // One bad event must not fail the batch — LINE would redeliver all. @@ -131,10 +134,14 @@ export class LineService { lineUserId: string, event: LineWebhookEvent, ): Promise { - const text = - typeof event.message?.text === 'string' ? event.message.text.trim() : ''; + const raw = + typeof event.message?.text === 'string' ? event.message.text : ''; + const text = normalizeLinkCode(raw); if (!LINK_CODE_PATTERN.test(text)) { + this.logger.log( + `LINE message from unbound ${maskId(lineUserId)} is not code-shaped (len=${raw.length}) — sending link button`, + ); await this.sendLinkButton(lineUserId); return; } @@ -153,11 +160,17 @@ export class LineService { throw new Error(`Failed to look up link code: ${codeError.message}`); } if (!codeRow) { + this.logger.warn( + `LINE link code from ${maskId(lineUserId)} not found or expired`, + ); await this.pushText(lineUserId, DM.codeInvalid); return; } const row = codeRow as { nonce: string; user_id: string }; + this.logger.log( + `LINE link code accepted for user ${row.user_id} from ${maskId(lineUserId)}`, + ); await this.bindProfile(lineUserId, row.user_id, row.nonce); } @@ -168,19 +181,34 @@ export class LineService { ): Promise { const client = this.supabaseService.getAdminClient(); - const { error: updateError } = await client + // .select() makes PostgREST return the updated rows, so a silently + // missing profiles row (0 rows updated) is detected instead of sending a + // false "linked" confirmation. + const { data: updated, error: updateError } = await client .from('profiles') .update({ line_id: lineUserId }) - .eq('id', userId); + .eq('id', userId) + .select('id'); if (updateError) { if (updateError.code === UNIQUE_VIOLATION) { + this.logger.warn( + `LINE bind rejected for user ${userId}: LINE account ${maskId(lineUserId)} already linked elsewhere`, + ); await this.pushText(lineUserId, DM.linkedElsewhere); return; } throw new Error(`Failed to bind LINE account: ${updateError.message}`); } + if (!updated || (updated as unknown[]).length === 0) { + this.logger.error( + `LINE bind failed for user ${userId}: no profiles row was updated`, + ); + await this.pushText(lineUserId, DM.linkFailed); + return; + } + await client.from('cw_line_link_nonces').delete().eq('nonce', nonce); await this.pushText(lineUserId, DM.linked); this.logger.log(`Linked LINE account for user ${userId}`); @@ -345,3 +373,19 @@ export class LineService { await this.lineApiClient.pushMessage(lineUserId, [{ type: 'text', text }]); } } + +/** + * Japanese keyboards commonly produce full-width digits (123456), and + * users paste codes with stray whitespace. Normalize to ASCII digits before + * matching — this is why the code flow "worked for some users only". + */ +function normalizeLinkCode(text: string): string { + return text + .replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)) + .replace(/\s+/g, ''); +} + +// LINE user ids in logs: enough to correlate, not enough to push to. +function maskId(lineUserId: string): string { + return `${lineUserId.slice(0, 6)}…`; +} From 2b320bb11e99e077a8e7cc773cff41499a869f96 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 29 Jul 2026 21:50:02 +0900 Subject: [PATCH 2/2] feat(line): eligible-recipients endpoint for per-rule targeting GET /v1/line/recipients?devEuis=a,b lists users with view access to the given devices (scoped to caller-viewable devices; direct owner + owners below DISABLED), with display-name fallback chain and lineLinked flag, for the rules-form recipient picker. Co-Authored-By: Claude Fable 5 --- .../v1-route-input-contract.spec.ts.snap | 14 ++ src/v1/common/v1-route-input-contract.spec.ts | 21 +++ src/v1/line/line.controller.ts | 41 ++++- src/v1/line/line.service.spec.ts | 145 ++++++++++++++++++ src/v1/line/line.service.ts | 79 ++++++++++ 5 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index 5a991d0..91b8993 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -1121,6 +1121,20 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "/v1/line/link-start": { "post": {}, }, + "/v1/line/recipients": { + "get": { + "parameters": [ + { + "in": "query", + "name": "devEuis", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + }, + }, "/v1/line/webhook": { "post": {}, }, diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index d65dda4..39b9060 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -370,6 +370,7 @@ describe('V1 Route Input Contracts', () => { 'handleEvents', 'createLinkNonce', 'createLinkCode', + 'listEligibleRecipients', 'unlink', ]), }; @@ -901,6 +902,18 @@ describe('V1 Route Input Contracts', () => { name: 'POST /v1/line/link-code mints a chat-linking code for the current user', url: '/v1/line/link-code', }, + { + auth: true, + expectedCall: { + args: [MOCK_USER, ['AA', 'BB']], + method: 'listEligibleRecipients', + service: 'line', + }, + expectedStatus: 200, + method: 'get', + name: 'GET /v1/line/recipients parses comma-separated devEuis', + url: '/v1/line/recipients?devEuis=AA,%20BB', + }, { auth: true, expectedCall: { @@ -923,6 +936,14 @@ describe('V1 Route Input Contracts', () => { name: 'POST /v1/line/webhook rejects requests without a raw body', url: '/v1/line/webhook', }, + { + auth: true, + expectedMessage: 'devEuis is required', + expectedStatus: 400, + method: 'get', + name: 'GET /v1/line/recipients rejects a missing devEuis param', + url: '/v1/line/recipients', + }, { auth: true, body: { diff --git a/src/v1/line/line.controller.ts b/src/v1/line/line.controller.ts index 81b776d..7989135 100644 --- a/src/v1/line/line.controller.ts +++ b/src/v1/line/line.controller.ts @@ -2,20 +2,31 @@ import { BadRequestException, Controller, Delete, + Get, Headers, HttpCode, HttpStatus, Post, + Query, Req, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import type { RawBodyRequest } from '@nestjs/common'; import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; -import { LineService, type LineWebhookEvent } from './line.service'; +import { + LineService, + type LineRecipientCandidate, + type LineWebhookEvent, +} from './line.service'; @ApiTags('line') @Controller({ path: 'line', version: '1' }) @@ -66,6 +77,32 @@ export class LineController { return this.lineService.createLinkNonce(user.sub); } + @Get('recipients') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'List users eligible as LINE recipients for the given devices', + }) + @ApiQuery({ + name: 'devEuis', + required: true, + type: String, + description: 'Comma-separated device EUIs', + }) + listRecipients( + @CurrentUser() user: AuthenticatedUser, + @Query('devEuis') devEuis?: string, + ): Promise { + const parsed = (devEuis ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (parsed.length === 0) { + throw new BadRequestException('devEuis is required'); + } + return this.lineService.listEligibleRecipients(user, parsed); + } + @Post('link-code') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/src/v1/line/line.service.spec.ts b/src/v1/line/line.service.spec.ts index da00ecd..b50baec 100644 --- a/src/v1/line/line.service.spec.ts +++ b/src/v1/line/line.service.spec.ts @@ -26,6 +26,7 @@ function chain(result: StubResult) { 'eq', 'gt', 'lt', + 'in', ]) { stub[method] = jest.fn((...args: unknown[]) => { calls.push({ method, args }); @@ -450,6 +451,150 @@ describe('LineService', () => { }); }); + describe('listEligibleRecipients', () => { + const caller = { + sub: 'user-1', + email: 'kevin@example.com', + isStaff: false, + }; + + it('scopes to caller-viewable devices, includes the owner, excludes DISABLED', async () => { + const managedLookup = chain({ + data: [ + { + dev_eui: 'DEV-A', + name: 'A', + user_id: 'user-1', + cw_device_owners: [], + }, + ], + error: null, + }); + const viewersLookup = chain({ + data: [ + { + user_id: 'owner-9', + cw_device_owners: [ + { user_id: 'viewer-4', permission_level: 4 }, + { user_id: 'disabled-5', permission_level: 5 }, + ], + }, + ], + error: null, + }); + const profilesLookup = chain({ + data: [ + { + id: 'owner-9', + full_name: 'Zoe Owner', + username: null, + email: null, + line_id: 'U9', + }, + { + id: 'viewer-4', + full_name: null, + username: null, + email: 'v4@example.com', + line_id: null, + }, + ], + error: null, + }); + const adminClient = buildAdminClient({ + cw_devices: [managedLookup, viewersLookup], + profiles: [profilesLookup], + }); + const { service } = createService({ adminClient }); + + const result = await service.listEligibleRecipients(caller, [ + 'DEV-A', + 'DEV-NOT-VISIBLE', + ]); + + // Scoped viewers query only includes the viewable device. + expect(viewersLookup.calls).toContainEqual({ + method: 'in', + args: ['dev_eui', ['DEV-A']], + }); + // Sorted by display name; owner included; DISABLED excluded; fallback + // chain and lineLinked flags applied. + expect(result).toEqual([ + { + userId: 'viewer-4', + displayName: 'v4@example.com', + lineLinked: false, + }, + { userId: 'owner-9', displayName: 'Zoe Owner', lineLinked: true }, + ]); + }); + + it('returns empty without further queries when the caller can view none', async () => { + const managedLookup = chain({ data: [], error: null }); + const adminClient = buildAdminClient({ cw_devices: [managedLookup] }); + const { service } = createService({ adminClient }); + + await expect( + service.listEligibleRecipients(caller, ['DEV-X']), + ).resolves.toEqual([]); + }); + + it('dedupes a user appearing on multiple devices', async () => { + const managedLookup = chain({ + data: [ + { + dev_eui: 'DEV-A', + name: 'A', + user_id: 'user-1', + cw_device_owners: [], + }, + { + dev_eui: 'DEV-B', + name: 'B', + user_id: 'user-1', + cw_device_owners: [], + }, + ], + error: null, + }); + const viewersLookup = chain({ + data: [ + { user_id: 'shared-user', cw_device_owners: [] }, + { user_id: 'shared-user', cw_device_owners: [] }, + ], + error: null, + }); + const profilesLookup = chain({ + data: [ + { + id: 'shared-user', + full_name: 'Shared', + username: null, + email: null, + line_id: null, + }, + ], + error: null, + }); + const adminClient = buildAdminClient({ + cw_devices: [managedLookup, viewersLookup], + profiles: [profilesLookup], + }); + const { service } = createService({ adminClient }); + + const result = await service.listEligibleRecipients(caller, [ + 'DEV-A', + 'DEV-B', + ]); + + expect(result).toHaveLength(1); + expect(profilesLookup.calls).toContainEqual({ + method: 'in', + args: ['id', ['shared-user']], + }); + }); + }); + describe('createLinkNonce / unlink', () => { it('purges expired nonces and inserts a fresh 10-minute nonce', async () => { const purge = chain({ data: null, error: null }); diff --git a/src/v1/line/line.service.ts b/src/v1/line/line.service.ts index caab292..7734f44 100644 --- a/src/v1/line/line.service.ts +++ b/src/v1/line/line.service.ts @@ -7,8 +7,17 @@ import { import { ConfigService } from '@nestjs/config'; import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'crypto'; import { SupabaseService } from '../../supabase/supabase.service'; +import { listManagedDevices } from '../common/managed-devices.helper'; +import { canRead } from '../common/permission-levels'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; import { LineApiClient, type LineMessage } from './line-api.client'; +export interface LineRecipientCandidate { + userId: string; + displayName: string; + lineLinked: boolean; +} + const APP_BASE_URL = 'https://app.cropwatch.io'; const NONCE_TTL_MS = 10 * 60 * 1000; const UNIQUE_VIOLATION = '23505'; @@ -325,6 +334,76 @@ export class LineService { throw new Error('Failed to allocate a unique link code'); } + // Users eligible as LINE recipients for a rule: everyone with view access + // to any of the given devices, scoped to devices the CALLER can view. + // Unlinked users are included (flagged) — they start receiving alerts the + // moment they link. + async listEligibleRecipients( + user: AuthenticatedUser, + devEuis: string[], + ): Promise { + const client = this.supabaseService.getAdminClient(); + + const managed = await listManagedDevices(client, user.sub, user.isStaff); + const viewable = new Set( + managed.filter((device) => device.canView).map((device) => device.devEui), + ); + const scoped = devEuis.filter((devEui) => viewable.has(devEui)); + if (scoped.length === 0) return []; + + const { data: devices, error: devicesError } = await client + .from('cw_devices') + .select('user_id, cw_device_owners(user_id, permission_level)') + .in('dev_eui', scoped); + + if (devicesError) { + throw new Error(`Failed to load device viewers: ${devicesError.message}`); + } + + const viewerIds = new Set(); + for (const device of (devices ?? []) as Array<{ + user_id: string | null; + cw_device_owners?: Array<{ + user_id: string | null; + permission_level: number | null; + }> | null; + }>) { + if (device.user_id) viewerIds.add(device.user_id); + for (const owner of device.cw_device_owners ?? []) { + if (owner.user_id && canRead(owner.permission_level)) { + viewerIds.add(owner.user_id); + } + } + } + if (viewerIds.size === 0) return []; + + const { data: profiles, error: profilesError } = await client + .from('profiles') + .select('id, full_name, username, email, line_id') + .in('id', [...viewerIds]); + + if (profilesError) { + throw new Error(`Failed to load profiles: ${profilesError.message}`); + } + + return ( + (profiles ?? []) as Array<{ + id: string; + full_name: string | null; + username: string | null; + email: string | null; + line_id: string | null; + }> + ) + .map((profile) => ({ + userId: profile.id, + displayName: + profile.full_name ?? profile.username ?? profile.email ?? profile.id, + lineLinked: profile.line_id != null, + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + } + async unlink(userId: string): Promise { // line_id is deliberately excluded from the PATCH-profile whitelist; // this service method is the only authenticated write path for it.