From 3b1ead72c8253e5b90c1e0c36c6ceeb11cfdd43f Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:06:34 +0000 Subject: [PATCH 01/25] =?UTF-8?q?feat(classroom):=20=E3=81=8A=E7=9F=A5?= =?UTF-8?q?=E3=82=89=E3=81=9B=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E3=81=A8?= =?UTF-8?q?=E5=85=88=E7=94=9F=E5=90=91=E3=81=91=E9=80=9A=E7=9F=A5API?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(#1111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit お知らせセンターの土台。ClassroomNotifications テーブル (PK teacherSub / SK notificationId=createdAt接頭辞) と、先生向けの GET /notifications (一覧+未読数) / POST /notifications/mark-read (ids省略で全既読) を追加。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D1 置き場所: 汎用お知らせ基盤ではなく classroom スタック所有にした。 受信者が先生 (teacherSub) で、既存の先生 ID Token 認証をそのまま 読み出しに使えるため。書き込みは admin スタックが名前規約 import で 行う (SharedAssignments と同じ N2 維持の形)。代替案: admin スタック 所有 (先生の読み出し認可を admin 側に作る必要があり不採用) - D2 既読API: mark-read は notificationIds 省略時に直近50件窓の未読を 全既読化 (パネルを開いたらバッジが消える UX 前提)。代替案: 1件ずつ 既読 (クリック時に都度更新。API往復が増えるため不採用) - D3 保持期間: TTL は書き手 (admin スタック) が付与する。通知は短命な 案内でありレコードではないため DESTROY / stream なし Co-Authored-By: Claude Fable 5 --- infra/smalruby-classroom/lambda/handler.ts | 102 +++++++++ .../tests/handler-notifications.test.ts | 202 ++++++++++++++++++ .../smalruby-classroom/lib/classroom-stack.ts | 47 ++++ 3 files changed, 351 insertions(+) create mode 100644 infra/smalruby-classroom/lambda/tests/handler-notifications.test.ts diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index f26f1bc1a91..ae527ee9360 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -34,6 +34,9 @@ const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-clas // みんなの課題 — nationwide shared assignment library (EPIC #1066) const SHARED_ASSIGNMENTS_TABLE = process.env.SHARED_ASSIGNMENTS_TABLE_NAME || 'SharedAssignments'; const SHARED_REPORTS_TABLE = process.env.SHARED_REPORTS_TABLE_NAME || 'SharedAssignmentReports'; +// お知らせ (notification center, EPIC #1111) — admin → teacher notices. The +// admin stack writes items; this API lists/marks-read for the teacher. +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; const SHARED_BUCKET = process.env.SHARED_BUCKET_NAME || 'smalruby-shared-assignments'; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || ''; @@ -100,6 +103,9 @@ const MAX_SUPPLEMENT_URL_LENGTH = 500; const MAX_AUTHOR_NAME_LENGTH = 30; const MAX_AUTHOR_AFFILIATION_LENGTH = 50; const MAX_SHARED_REPORT_REASON_LENGTH = 200; +// お知らせの一覧窓(= mark-read が既読化する範囲)。通知は短命な案内なので +// 直近 50 件で足りる想定(TTL は書き手の admin スタックが付ける)。 +const NOTIFICATION_LIST_LIMIT = 50; // Class (旧組) v2 data model: every assignment (Classrooms record) belongs to // a class (ClassroomGroups record), and class-level GC linkage / co-teachers / @@ -4328,6 +4334,91 @@ async function handleReportSharedAssignment( return { statusCode: 201, body: JSON.stringify({}) }; } +// --- お知らせ (notification center, EPIC #1111) --- +// Items are written by the admin stack with this shape: +// { teacherSub, notificationId (createdAt-prefixed for chronological SK), +// type, title, body, link?, createdBy, createdAt, ttl } +// This API is deliberately read-only for teachers (list + mark-read): the +// single writer stays on the admin side, so notices cannot be forged from +// the editor. + +async function handleListNotifications( + identity: TeacherIdentity, +): Promise { + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + // notificationId starts with the ISO createdAt → newest first. + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + const notifications = (result.Items || []).map(item => ({ + notificationId: item.notificationId, + type: item.type || 'admin_message', + title: item.title, + body: item.body || null, + link: item.link || null, + readAt: item.readAt || null, + createdAt: item.createdAt, + })); + const unreadCount = notifications.filter(n => !n.readAt).length; + return { statusCode: 200, body: JSON.stringify({ notifications, unreadCount }) }; +} + +async function handleMarkNotificationsRead( + identity: TeacherIdentity, + body: Record, +): Promise { + let ids: string[]; + if (body.notificationIds === undefined || body.notificationIds === null) { + // No explicit ids = mark everything currently unread, bounded by the + // same window the list endpoint shows, so "open panel → clear badge" + // stays consistent with what the teacher actually saw. + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + ids = (result.Items || []) + .filter(item => !item.readAt) + .map(item => String(item.notificationId)); + } else if ( + Array.isArray(body.notificationIds) && + body.notificationIds.length <= NOTIFICATION_LIST_LIMIT && + body.notificationIds.every(id => typeof id === 'string') + ) { + ids = body.notificationIds as string[]; + } else { + throw new ValidationError( + `notificationIds must be an array of at most ${NOTIFICATION_LIST_LIMIT} strings`, + ); + } + + const now = new Date().toISOString(); + let updated = 0; + for (const notificationId of ids) { + try { + await docClient.send(new UpdateCommand({ + TableName: NOTIFICATIONS_TABLE, + // The key includes the caller's own teacherSub, so a teacher can + // never touch another teacher's rows regardless of the ids sent. + Key: { teacherSub: identity.sub, notificationId }, + UpdateExpression: 'SET readAt = if_not_exists(readAt, :now)', + // Never create phantom rows for ids that don't exist (TTL races). + ConditionExpression: 'attribute_exists(notificationId)', + ExpressionAttributeValues: { ':now': now }, + })); + updated += 1; + } catch (err) { + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + } + return { statusCode: 200, body: JSON.stringify({ updated }) }; +} + // --- Main handler --- export const handler = async (event: APIGatewayProxyEventV2): Promise => { @@ -4379,6 +4470,17 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +interface MakeEventOptions { + body?: unknown; + token?: string; +} + +const makeEvent = ( + method: string, + path: string, + { body, token }: MakeEventOptions = {}, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'http://localhost:8601', + }, + pathParameters: {}, + queryStringParameters: undefined, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const notice = (id: string, extra: Record = {}) => ({ + teacherSub: 'dev-test-teacher', + notificationId: id, + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + createdBy: 'admin@example.com', + createdAt: id.slice(0, 24), + ...extra, +}); + +describe('お知らせセンター (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + describe('GET /notifications', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('GET', '/notifications')); + expect(res.statusCode).toBe(401); + }); + + test('returns the caller-scoped inbox with unreadCount', async () => { + mockSend.mockImplementation(async (command) => { + expect(command.constructor.name).toBe('QueryCommand'); + expect(command.input.ExpressionAttributeValues[':sub']).toBe('dev-test-teacher'); + expect(command.input.ScanIndexForward).toBe(false); + return { + Items: [ + notice('2026-07-25T01:00:00.000Z#b'), + notice('2026-07-24T01:00:00.000Z#a', { readAt: '2026-07-24T02:00:00.000Z' }), + ], + }; + }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body || '{}'); + expect(data.unreadCount).toBe(1); + expect(data.notifications).toHaveLength(2); + expect(data.notifications[0]).toEqual({ + notificationId: '2026-07-25T01:00:00.000Z#b', + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + readAt: null, + createdAt: '2026-07-25T01:00:00.000Z', + }); + // Internal fields never leak to the editor. + expect(res.body).not.toContain('createdBy'); + expect(res.body).not.toContain('teacherSub'); + }); + + test('empty inbox returns zero unread', async () => { + mockSend.mockResolvedValue({ Items: [] }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ notifications: [], unreadCount: 0 }); + }); + }); + + describe('POST /notifications/mark-read', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { body: {} })); + expect(res.statusCode).toBe(401); + }); + + test('marks the given ids read, keyed to the caller', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['n1', 'n2'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates).toHaveLength(2); + expect(updates[0].Key).toEqual({ teacherSub: 'dev-test-teacher', notificationId: 'n1' }); + // Never create phantom rows; never overwrite an earlier readAt. + expect(updates[0].ConditionExpression).toContain('attribute_exists'); + expect(updates[0].UpdateExpression).toContain('if_not_exists(readAt'); + }); + + test('without ids marks everything currently unread', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'QueryCommand') { + return { + Items: [ + notice('n3'), + notice('n2', { readAt: '2026-07-24T02:00:00.000Z' }), + notice('n1'), + ], + }; + } + updates.push(command.input); + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: {}, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates.map(u => (u.Key as Record).notificationId)).toEqual(['n3', 'n1']); + }); + + test('ids that vanished (TTL race) are skipped, not errors', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + const err = new Error('conditional failed') as Error & { name: string }; + err.name = 'ConditionalCheckFailedException'; + throw err; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['gone'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 0 }); + }); + + test('400 on malformed notificationIds', async () => { + for (const bad of ['x', [1, 2], Array.from({ length: 51 }, (_, i) => `n${i}`)]) { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: bad }, + })); + expect(res.statusCode).toBe(400); + } + }); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 15e7f7f3913..cf0c8005afb 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -21,6 +21,7 @@ export class ClassroomStack extends cdk.Stack { public readonly groupsTable: dynamodb.Table; public readonly sharedAssignmentsTable: dynamodb.Table; public readonly sharedReportsTable: dynamodb.Table; + public readonly notificationsTable: dynamodb.Table; public readonly submissionsBucket: s3.Bucket; public readonly sharedBucket: s3.Bucket; public readonly api: apigatewayv2.HttpApi; @@ -332,6 +333,35 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.sharedReportsTable).add('ResourceType', 'DynamoDB'); + // --- お知らせ (notification center, EPIC #1111) --- + // Admin → teacher notices surfaced in the class management UI. This stack + // owns the table because teachers read it through this API with their + // existing ID-token auth; the admin stack imports it by the fleet's stage + // naming convention and only writes (mirror of the SharedAssignments + // arrangement — N2: the admin stack never modifies this stack). + // PK teacherSub / SK notificationId (createdAt-prefixed → chronological), + // so a single Query serves the per-teacher inbox. Notices are ephemeral + // guidance, not records: TTL is set by the writer (admin stack). + this.notificationsTable = new dynamodb.Table(this, 'NotificationsTable', { + tableName: `ClassroomNotifications${stageSuffix}`, + partitionKey: { + name: 'teacherSub', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'notificationId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: false, + }, + timeToLiveAttribute: 'ttl', + }); + + cdk.Tags.of(this.notificationsTable).add('ResourceType', 'DynamoDB'); + // --- S3 Bucket for submissions --- this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', { @@ -407,6 +437,7 @@ export class ClassroomStack extends cdk.Stack { GROUPS_TABLE_NAME: this.groupsTable.tableName, SHARED_ASSIGNMENTS_TABLE_NAME: this.sharedAssignmentsTable.tableName, SHARED_REPORTS_TABLE_NAME: this.sharedReportsTable.tableName, + NOTIFICATIONS_TABLE_NAME: this.notificationsTable.tableName, SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName, SHARED_BUCKET_NAME: this.sharedBucket.bucketName, SHARE_DAILY_LIMIT: process.env.SHARE_DAILY_LIMIT || '10', @@ -445,6 +476,7 @@ export class ClassroomStack extends cdk.Stack { this.groupsTable.grantReadWriteData(handlerFn); this.sharedAssignmentsTable.grantReadWriteData(handlerFn); this.sharedReportsTable.grantReadWriteData(handlerFn); + this.notificationsTable.grantReadWriteData(handlerFn); this.submissionsBucket.grantPut(handlerFn); this.submissionsBucket.grantRead(handlerFn); this.sharedBucket.grantPut(handlerFn); @@ -692,6 +724,21 @@ export class ClassroomStack extends cdk.Stack { integration, }); + // お知らせ (notification center #1111) — teacher-facing inbox. Own root + // path; writes happen from the admin stack, so this API only lists and + // marks-read for the authenticated teacher. + this.api.addRoutes({ + path: '/notifications', + methods: [apigatewayv2.HttpMethod.GET], + integration, + }); + + this.api.addRoutes({ + path: '/notifications/mark-read', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + // Groups (組) — teacher-side organizing concept. Own root path so the // /classrooms/{classroomId} patterns never shadow it. this.api.addRoutes({ From ab982df8ed8cc024b9198b0bfdc6d1585e9a3220 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:09:24 +0000 Subject: [PATCH 02/25] =?UTF-8?q?feat(admin):=20=E3=81=8A=E7=9F=A5?= =?UTF-8?q?=E3=82=89=E3=81=9B=E9=80=81=E4=BF=A1API=20(classroomId=20?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E3=83=BBaudit=E4=BB=98=E3=81=8D)=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#1111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/notifications。SPA からは classroomId だけを送り、宛先の teacherSub はサーバー側で解決して ClassroomNotifications へ書き込む。 テーブルは classroom スタック所有を名前規約 import (N2) し、この スタックが単一の書き手 (write-only grant)。全送信を audit() 記録。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D4 宛先指定: teacherSub を admin SPA へ渡さず classroomId で指定。 admin 投影が authorSub/teacherSub を意図的に隠す既存原則の踏襲。 代替案: teacherSub 直接指定 (内部IDの露出が増えるため不採用) - D5 grant: 読みを許可せず grantWriteData のみ。読み出し導線は 先生側 (classroom API) に限定し、単一ライターの非対称を IAM でも 表現。代替案: RW grant (送信履歴の admin 表示が必要になったら拡張) - link.kind は whitelist ('classroom' のみ)。後続 Issue (#1110/#1106) で kind を増やす際はここに追加する Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/smalruby-admin/lambda/handler.ts | 105 +++++++++++ .../tests/handler-notifications.test.ts | 170 ++++++++++++++++++ infra/smalruby-admin/lib/admin-stack.ts | 13 ++ 3 files changed, 288 insertions(+) create mode 100644 infra/smalruby-admin/lambda/tests/handler-notifications.test.ts diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index ff70063aa4c..c76d9e8e460 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -27,6 +27,7 @@ import { import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { OAuth2Client } from 'google-auth-library'; +import { randomUUID } from 'crypto'; import { buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot, } from './restore-plan'; @@ -50,6 +51,13 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups'; const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions'; const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10); +// お知らせ (notification center, EPIC #1111): this stack is the single +// writer; teachers read through the classroom API. Notices are ephemeral +// guidance, so the writer stamps a TTL (default 180 days). +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; +const NOTIFICATION_TTL_DAYS = parseInt(process.env.NOTIFICATION_TTL_DAYS || '180', 10); +const MAX_NOTIFICATION_TITLE_LENGTH = 100; +const MAX_NOTIFICATION_MESSAGE_LENGTH = 1000; const dynamoClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(dynamoClient); @@ -495,6 +503,100 @@ async function handleSetClassroomStatus( }; } +// --- お知らせ (notification center, EPIC #1111) --- + +/** + * Notification link targets the teacher UI knows how to open. Kept as a + * whitelist so a typo'd kind can never be stored (the editor ignores + * unknown kinds, but the audit trail should stay clean). + */ +const NOTIFICATION_LINK_KINDS = new Set(['classroom']); + +/** + * Write one notice into the teacher's inbox (single-writer: only this stack + * ever creates rows; the classroom API lists/marks-read them). + * @param teacherSub - recipient (resolved internally, never sent by the SPA) + * @param fields - type/title/body/link + the acting admin's email + * @returns the created notificationId + */ +async function putNotification( + teacherSub: string, + fields: { + type: string; + title: string; + body: string; + link: Record | null; + createdBy: string; + }, +): Promise { + const createdAt = new Date().toISOString(); + // createdAt-prefixed sort key → the inbox Query returns newest first. + const notificationId = `${createdAt}#${randomUUID()}`; + await docClient.send(new PutCommand({ + TableName: NOTIFICATIONS_TABLE, + Item: { + teacherSub, + notificationId, + type: fields.type, + title: fields.title, + body: fields.body, + ...(fields.link ? { link: fields.link } : {}), + createdBy: fields.createdBy, + createdAt, + ttl: Math.floor(Date.now() / 1000) + NOTIFICATION_TTL_DAYS * 24 * 60 * 60, + }, + })); + return notificationId; +} + +/** + * POST /admin/notifications — send a notice to the teacher who owns the + * given classroom. The SPA sends a classroomId, never a teacherSub: subs + * stay internal (same principle as authorSub in the shared projections). + */ +async function handleSendNotification( + identity: AdminIdentity, body: Record, +): Promise { + const classroomId = typeof body.classroomId === 'string' ? body.classroomId.trim() : ''; + if (!classroomId) { + throw new ValidationError('classroomId is required'); + } + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title || title.length > MAX_NOTIFICATION_TITLE_LENGTH) { + throw new ValidationError(`title is required (at most ${MAX_NOTIFICATION_TITLE_LENGTH} characters)`); + } + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (!message || message.length > MAX_NOTIFICATION_MESSAGE_LENGTH) { + throw new ValidationError(`message is required (at most ${MAX_NOTIFICATION_MESSAGE_LENGTH} characters)`); + } + + const result = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + const teacherSub = result.Item && typeof result.Item.teacherSub === 'string' + ? result.Item.teacherSub + : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + + const link = { kind: 'classroom', classroomId }; + if (!NOTIFICATION_LINK_KINDS.has(link.kind)) { + throw new ValidationError('Unsupported link kind'); + } + const notificationId = await putNotification(teacherSub, { + type: 'admin_message', + title, + body: message, + link, + createdBy: identity.email, + }); + audit('notification.send', identity, { classroomId, type: 'admin_message' }); + + return { statusCode: 201, body: JSON.stringify({ notificationId }) }; +} + // --- ddb-archive snapshot helpers --- async function readSnapshot(key: string): Promise { @@ -775,6 +877,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, token?: string, body?: unknown) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'https://smalruby.app', + }, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +describe('POST /admin/notifications (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + /** + * Route DynamoDB commands: the allowlist Get (SmalrubyAdmins), the + * classroom Get, and the notification Put. + * @param classroom - the Classrooms item returned for the lookup (null = missing) + * @returns collected PutCommand inputs + */ + const wireMocks = (classroom: Record | null) => { + const puts: Array> = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('Classrooms')) { + return { Item: classroom }; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return puts; + }; + + test('writes a notice addressed to the classroom owner, 201', async () => { + const puts = wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', + title: '運営からのお知らせ', + message: 'この課題、みんなの課題に共有しませんか?', + })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body as string).notificationId).toBeTruthy(); + expect(puts).toHaveLength(1); + const item = puts[0].Item as Record; + expect(item.teacherSub).toBe('teacher-sub-9'); + expect(item.type).toBe('admin_message'); + expect(item.title).toBe('運営からのお知らせ'); + expect(item.body).toBe('この課題、みんなの課題に共有しませんか?'); + expect(item.link).toEqual({ kind: 'classroom', classroomId: 'c1' }); + expect(item.createdBy).toBe('dev-admin@example.com'); + // createdAt-prefixed sort key (chronological inbox) + TTL stamped. + expect(String(item.notificationId)).toMatch(/^\d{4}-\d{2}-\d{2}T.*#[0-9a-f-]+$/); + expect(typeof item.ttl).toBe('number'); + }); + + test('404 when the classroom does not exist (nothing written)', async () => { + const puts = wireMocks(null); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'missing', + title: 't', + message: 'm', + })); + expect(res.statusCode).toBe(404); + expect(puts).toHaveLength(0); + }); + + test('400 on missing/oversized fields', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const cases = [ + { title: 't', message: 'm' }, // no classroomId + { classroomId: 'c1', message: 'm' }, // no title + { classroomId: 'c1', title: 't' }, // no message + { classroomId: 'c1', title: 'x'.repeat(101), message: 'm' }, + { classroomId: 'c1', title: 't', message: 'x'.repeat(1001) }, + ]; + for (const body of cases) { + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, body)); + expect(res.statusCode).toBe(400); + } + }); + + test('401 without a token, 403 for a non-admin', async () => { + let res = await handler(makeEvent('POST', '/admin/notifications', undefined, {})); + expect(res.statusCode).toBe(401); + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ + sub: 'stranger', email: 'stranger@example.com', email_verified: true, name: null, + }), + }); + mockSend.mockResolvedValue({}); // no allowlist row + res = await handler(makeEvent('POST', '/admin/notifications', 'stranger-token', { + classroomId: 'c1', title: 't', message: 'm', + })); + expect(res.statusCode).toBe(403); + }); + + test('audit log records the send', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', title: 't', message: 'm', + })); + const auditLines = logSpy.mock.calls + .map(args => String(args[0])) + .filter(line => line.includes('"audit":true')); + expect(auditLines.some(line => line.includes('"action":"notification.send"'))).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 1253ba5d4a7..33e39bec4ac 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -111,6 +111,8 @@ export class SmalrubyAdminStack extends cdk.Stack { SUBMISSIONS_TABLE_NAME: `ClassroomSubmissions${stageSuffix}`, GROUPS_TABLE_NAME: `ClassroomGroups${stageSuffix}`, SUBMISSIONS_BUCKET_NAME: `smalruby-classroom-submissions${stageSuffix}`, + NOTIFICATIONS_TABLE_NAME: `ClassroomNotifications${stageSuffix}`, + NOTIFICATION_TTL_DAYS: process.env.NOTIFICATION_TTL_DAYS || '180', STAGE: stage, }, bundling: { @@ -176,6 +178,14 @@ export class SmalrubyAdminStack extends cdk.Stack { groupsTable.grantReadWriteData(handlerFn); submissionsBucket.grantRead(handlerFn); + // お知らせ (notification center, EPIC #1111): this stack is the single + // WRITER of teacher notices; reads happen through the classroom API with + // the teacher's own auth. Write-only grant keeps that asymmetry honest. + const notificationsTable = dynamodb.Table.fromTableAttributes(this, 'NotificationsRef', { + tableName: `ClassroomNotifications${stageSuffix}`, + }); + notificationsTable.grantWriteData(handlerFn); + // --- Custom Domain --- const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; @@ -274,6 +284,9 @@ export class SmalrubyAdminStack extends cdk.Stack { addRoute('/admin/classrooms/{classroomId}/restore-plan', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/{classroomId}/restore', [apigatewayv2.HttpMethod.POST]); + // お知らせ送信 (notification center #1111) + addRoute('/admin/notifications', [apigatewayv2.HttpMethod.POST]); + // Single-operator tool: a human never needs more than a couple of // requests per second, so throttle hard to cap the API-GW request bill an // attacker can run up (the JWT authorizer already caps Lambda/log cost). From 688ac728574e7fac7f48a20ccd58d8946fdc952c Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:11:44 +0000 Subject: [PATCH 03/25] =?UTF-8?q?feat(admin):=20=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=B9=E8=A9=B3=E7=B4=B0=E3=81=AB=E5=85=88=E7=94=9F=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E3=81=8A=E7=9F=A5=E3=82=89=E3=81=9B=E9=80=81=E4=BF=A1?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=A0=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(#1111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クラス検索/俯瞰候補から開くクラス詳細の下部に「先生へのお知らせ」 パネルを追加。タイトル+本文を入力し、既存の二段階確認 (arm/confirm) を通って POST /admin/notifications を呼ぶ。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D6 送信UIの置き場所: クラス詳細 (ClassroomDetail) 内に配置。 俯瞰の有益候補クリック→詳細→送信、検索→詳細→送信の両動線が 1 箇所で済むため。代替案: 専用タブ/俯瞰の行内ボタン (対象クラスの 文脈確認なしに送れてしまうため不採用) - 既存の mutation 規約 (二段階確認・本文必須で無効化) を踏襲 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/admin/src/components/app.css | 21 ++++ .../admin/src/components/classrooms-view.jsx | 103 ++++++++++++++++++ packages/admin/src/lib/admin-api.js | 16 ++- .../admin/test/unit/classrooms-view.test.jsx | 29 ++++- 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/components/app.css b/packages/admin/src/components/app.css index 988e2902790..b29a6c7dd98 100644 --- a/packages/admin/src/components/app.css +++ b/packages/admin/src/components/app.css @@ -155,6 +155,27 @@ body { margin-top: 0.75rem; } +/* Bordered sub-section inside a detail view (お知らせ送信 #1111 など). */ +.admin-panel { + margin-top: 1.25rem; + padding: 0.75rem; + border: 1px solid hsl(220, 15%, 88%); + border-radius: 0.4rem; + background: white; +} + +.admin-panel input, +.admin-panel textarea { + display: block; + width: 100%; + max-width: 32rem; + margin: 0.4rem 0; + padding: 0.4rem; + border: 1px solid hsl(220, 15%, 75%); + border-radius: 0.4rem; + font: inherit; +} + .admin-actions button { padding: 0.4rem 0.9rem; border: 1px solid hsl(220, 15%, 75%); diff --git a/packages/admin/src/components/classrooms-view.jsx b/packages/admin/src/components/classrooms-view.jsx index b3c027da1ae..6ac49303aba 100644 --- a/packages/admin/src/components/classrooms-view.jsx +++ b/packages/admin/src/components/classrooms-view.jsx @@ -16,6 +16,7 @@ import { fetchClassrooms, fetchRestoreCandidates, fetchRestorePlan, + sendNotification, setClassroomStatus } from '../lib/admin-api.js'; import ClassroomOverviewView from './classroom-overview-view.jsx'; @@ -34,6 +35,107 @@ const formatDate = iso => (iso ? iso.replace('T', ' ').slice(0, 16) : '-'); const itemLine = (item, tail) => `課題: ${item.assignmentName || '-'} ・ コード: ${item.joinCode} ・ ${tail}`; +// お知らせ送信 (notification center #1111): この課題を作った先生の +// クラス管理画面右上「お知らせ」に届く。宛先はサーバー側で classroomId +// から解決される(teacherSub は SPA に出さない)。 +const NotificationSendPanel = ({classroomId}) => { + const [title, setTitle] = useState('運営からのお知らせ'); + const [message, setMessage] = useState(''); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + + const handleTitle = useCallback(e => setTitle(e.target.value), []); + const handleMessage = useCallback(e => setMessage(e.target.value), []); + const handleArm = useCallback(() => { + setSent(false); + setError(''); + setConfirming(true); + }, []); + const handleDisarm = useCallback(() => setConfirming(false), []); + const handleSend = useCallback(async () => { + setBusy(true); + setError(''); + try { + await sendNotification(classroomId, {title: title.trim(), message: message.trim()}); + setConfirming(false); + setSent(true); + setMessage(''); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }, [classroomId, title, message]); + + return ( +
+

{'先生へのお知らせ'}

+

+ {'この課題を作成した先生のクラス管理画面(右上「お知らせ」)に届きます。'} +

+ +