Skip to content

Commit e715cd3

Browse files
authored
Merge pull request #29 from ut42tech/develop
Add Google Drive folder creation and participant nickname search functionality
2 parents 41f84ff + d21ed3a commit e715cd3

7 files changed

Lines changed: 499 additions & 47 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ GOOGLE_SERVICE_ACCOUNT_KEY=
3333
# URLの `https://docs.google.com/spreadsheets/d/{ここの部分}/edit` を抜き出し
3434
GOOGLE_SHEETS_ID=
3535

36+
# -----------------------------------------------------------------------------
37+
# Google Drive folder creation via Apps Script
38+
# -----------------------------------------------------------------------------
39+
# 事前登録アクティベート時に利用者別 Google Drive フォルダを作る GAS Web App。
40+
# 本番は Wrangler Secrets、ローカルは apps/api/.dev.vars に設定する。
41+
GAS_DRIVE_WEBHOOK_URL=
42+
GAS_DRIVE_WEBHOOK_SECRET=
43+
3644
# -----------------------------------------------------------------------------
3745
# Better Auth (Google OAuth)
3846
# -----------------------------------------------------------------------------

apps/api/src/index.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
createMentorRequestSchema,
99
createPreRegistrationRequestSchema,
1010
historyBulkCheckOutRequestSchema,
11+
participantSearchQuerySchema,
1112
participantsListQuerySchema,
1213
scanRequestSchema,
1314
updateMentorRequestSchema,
1415
} from '@tecnova/shared/schemas';
1516
import { and, count, eq } from 'drizzle-orm';
1617
import { drizzle } from 'drizzle-orm/d1';
17-
import { Hono } from 'hono';
18+
import { type Context, Hono } from 'hono';
1819
import { cors } from 'hono/cors';
1920
import type { MiddlewareHandler } from 'hono/types';
2021
import type { ContentfulStatusCode } from 'hono/utils/http-status';
@@ -39,7 +40,9 @@ import {
3940
recordBulkCheckOut,
4041
recordCheckIn,
4142
recordCheckOut,
43+
searchActiveParticipantsByNickname,
4244
} from './lib/checkin';
45+
import { createParticipantDriveFolder } from './lib/drive-folder';
4346
import {
4447
createPreRegistration,
4548
deletePreRegistration,
@@ -52,6 +55,8 @@ type Bindings = {
5255
DB: D1Database;
5356
GOOGLE_SERVICE_ACCOUNT_KEY: string;
5457
GOOGLE_SHEETS_ID: string;
58+
GAS_DRIVE_WEBHOOK_URL?: string;
59+
GAS_DRIVE_WEBHOOK_SECRET?: string;
5560
GOOGLE_OAUTH_CLIENT_ID: string;
5661
GOOGLE_OAUTH_CLIENT_SECRET: string;
5762
BETTER_AUTH_SECRET: string;
@@ -362,6 +367,36 @@ const internalError = (e: unknown) => ({
362367
message: e instanceof Error ? e.message : String(e),
363368
});
364369

370+
const queueDriveFolderCreation = (
371+
c: Context<{ Bindings: Bindings; Variables: Variables }>,
372+
participantId: string,
373+
nickname: string,
374+
) => {
375+
const url = c.env.GAS_DRIVE_WEBHOOK_URL?.trim();
376+
const secret = c.env.GAS_DRIVE_WEBHOOK_SECRET?.trim();
377+
if (!url && !secret) return;
378+
if (!url || !secret) {
379+
console.warn('GAS Drive webhook is partially configured; skipping folder creation');
380+
return;
381+
}
382+
383+
const promise = createParticipantDriveFolder({ url, secret, participantId, nickname })
384+
.then((folder) => {
385+
console.log(
386+
`Drive folder ready for participant ${participantId}: ${folder.folderName} (${folder.folderId}) reused=${folder.reused}`,
387+
);
388+
})
389+
.catch((e) => {
390+
console.error(`Drive folder creation failed for participant ${participantId}:`, e);
391+
});
392+
393+
try {
394+
c.executionCtx.waitUntil(promise);
395+
} catch {
396+
void promise;
397+
}
398+
};
399+
365400
const serializeScanResult = (result: Awaited<ReturnType<typeof processScanValue>>) => {
366401
if (result.action === 'check_in') {
367402
return {
@@ -433,6 +468,7 @@ app.post('/checkin/activate', async (c) => {
433468
spreadsheetId: c.env.GOOGLE_SHEETS_ID,
434469
preRegistrationId: parsed.data.preRegistrationId,
435470
});
471+
queueDriveFolderCreation(c, result.participantId, result.nickname);
436472
return c.json({
437473
participantId: result.participantId,
438474
nickname: result.nickname,
@@ -536,6 +572,23 @@ app.post('/checkin/history/check-out-bulk', async (c) => {
536572
}
537573
});
538574

575+
// マニュアル入力画面のニックネーム検索。`:participantId` ルートより前に
576+
// 登録する必要があるので注意(Hono は登録順マッチ)。
577+
app.get('/checkin/participants/search', async (c) => {
578+
const parsed = participantSearchQuerySchema.safeParse({ q: c.req.query('q') });
579+
if (!parsed.success) {
580+
return c.json(invalidQueryError, 400);
581+
}
582+
583+
const db = createDb(c.env);
584+
try {
585+
const items = await searchActiveParticipantsByNickname(db, parsed.data.q);
586+
return c.json({ participants: items });
587+
} catch (e) {
588+
return c.json(internalError(e), 500);
589+
}
590+
});
591+
539592
// 受付専用の参加者プロフィール。QR/手入力後はまずここを表示し、
540593
// 現在の状態に応じた操作だけをフロントに出す。
541594
app.get('/checkin/participants/:participantId', async (c) => {

apps/api/src/lib/checkin.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type * as schema from '@tecnova/db';
22
import { events, participants, sessions } from '@tecnova/db';
33
import { fetchSheetRows, updateSheetRow } from '@tecnova/shared/google-sheets';
4-
import type { TodaySessionsResponse } from '@tecnova/shared/schemas';
5-
import { and, desc, eq, inArray, isNull, like } from 'drizzle-orm';
4+
import type { ParticipantSearchItem, TodaySessionsResponse } from '@tecnova/shared/schemas';
5+
import { and, asc, desc, eq, inArray, isNull, like } from 'drizzle-orm';
66
import type { DrizzleD1Database } from 'drizzle-orm/d1';
77

88
type Db = DrizzleD1Database<typeof schema>;
@@ -424,6 +424,26 @@ export const fetchParticipantProfile = async (
424424
};
425425
};
426426

427+
// マニュアル入力画面のニックネーム検索。QR が読めない場面で使うので
428+
// 件数は実用上の上限(同名複数 + タイポ救済)として 50 件に制限する。
429+
export const searchActiveParticipantsByNickname = async (
430+
db: Db,
431+
query: string,
432+
limit = 50,
433+
): Promise<ParticipantSearchItem[]> => {
434+
const rows = await db
435+
.select({
436+
id: participants.id,
437+
nickname: participants.nickname,
438+
grade: participants.grade,
439+
})
440+
.from(participants)
441+
.where(and(eq(participants.active, true), like(participants.nickname, `%${query}%`)))
442+
.orderBy(asc(participants.nickname), asc(participants.id))
443+
.limit(limit);
444+
return rows;
445+
};
446+
427447
export const fetchReceptionHistoryToday = async (db: Db): Promise<TodaySessionsResponse> => {
428448
const [event] = await db
429449
.select({ id: events.id, date: events.date })

apps/api/src/lib/drive-folder.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export interface DriveFolderWebhookConfig {
2+
url: string;
3+
secret: string;
4+
}
5+
6+
export interface ParticipantDriveFolderResult {
7+
folderId: string;
8+
folderName: string;
9+
reused: boolean;
10+
}
11+
12+
interface CreateParticipantDriveFolderInput extends DriveFolderWebhookConfig {
13+
participantId: string;
14+
nickname: string;
15+
}
16+
17+
const isRecord = (value: unknown): value is Record<string, unknown> =>
18+
typeof value === 'object' && value !== null;
19+
20+
const readJsonResponse = async (resp: Response): Promise<unknown> => {
21+
const text = await resp.text();
22+
if (!text) return null;
23+
24+
try {
25+
return JSON.parse(text);
26+
} catch {
27+
throw new Error(`GAS Drive webhook returned non-JSON (${resp.status}): ${text.slice(0, 500)}`);
28+
}
29+
};
30+
31+
export const createParticipantDriveFolder = async ({
32+
url,
33+
secret,
34+
participantId,
35+
nickname,
36+
}: CreateParticipantDriveFolderInput): Promise<ParticipantDriveFolderResult> => {
37+
const resp = await fetch(url, {
38+
method: 'POST',
39+
headers: { 'Content-Type': 'application/json' },
40+
body: JSON.stringify({ secret, participantId, nickname }),
41+
});
42+
43+
const data = await readJsonResponse(resp);
44+
45+
if (!resp.ok) {
46+
throw new Error(`GAS Drive webhook failed with HTTP ${resp.status}: ${JSON.stringify(data)}`);
47+
}
48+
49+
if (!isRecord(data) || data.ok !== true) {
50+
const message = isRecord(data)
51+
? (data.error ?? data.message ?? JSON.stringify(data))
52+
: JSON.stringify(data);
53+
throw new Error(`GAS Drive webhook rejected request: ${String(message)}`);
54+
}
55+
56+
if (typeof data.folderId !== 'string') {
57+
throw new Error(`GAS Drive webhook returned invalid payload: ${JSON.stringify(data)}`);
58+
}
59+
60+
return {
61+
folderId: data.folderId,
62+
folderName:
63+
typeof data.folderName === 'string' ? data.folderName : `${participantId}_${nickname}`,
64+
reused: data.reused === true,
65+
};
66+
};

0 commit comments

Comments
 (0)