diff --git a/apps/classbot/.env.example b/apps/classbot/.env.example index dd33f4e..3524096 100644 --- a/apps/classbot/.env.example +++ b/apps/classbot/.env.example @@ -25,3 +25,12 @@ KAKAO_EVENT_API_BASE=https://bot-api.kakao.com CLASSBOT_CLASS_CODE=2-4 CLASSBOT_CLASS_NAME=2학년 4반 CLASSBOT_TIMEZONE=Asia/Seoul + +# Google Drive 자료실. 기본값은 이름이 정확히 `구민준`인 Quilo 계정이다. +# 폴더는 해당 Quilo OAuth 연결로 `Quilo schedule 자료실`을 자동 생성한다. +CLASSBOT_GOOGLE_DRIVE_OWNER_NAME=구민준 +# UUID override는 동명이인 등 이름 조회를 쓸 수 없을 때만 지정한다. +# CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID= +CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME=Quilo schedule 자료실 +# drive.file scope로 같은 Quilo 앱이 만든 폴더만 override할 수 있다. +# CLASSBOT_GOOGLE_DRIVE_FOLDER_ID= diff --git a/apps/classbot/README.md b/apps/classbot/README.md index b1c231b..1e0e602 100644 --- a/apps/classbot/README.md +++ b/apps/classbot/README.md @@ -1,17 +1,18 @@ # Quilo schedule -2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 학생은 최초 한 번 이름과 초대 코드로 본인 확인한 뒤 웹과 카카오에서 이름을 반복 입력하지 않아도 된다. +2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 웹은 기존 Quilo 계정 이름이 16명 명단과 정확히 일치할 때 열리고, 카카오는 최초 한 번 명단 이름을 등록한 뒤 이름을 반복 입력하지 않아도 된다. ## 현재 구현 범위 -- 관리자용 오늘 대시보드, 일정, 시간표, 반 공지, 자료실, 구성원, 알림 기록, 설정 +- 관리자·학생 공통 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정 - 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브 -- 최대 16명 구성원과 1회용 초대 코드 가입, 30일 HttpOnly 학생 포털 세션 -- 카카오 챗봇 명령: `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `자료 목록`, `PDF 별칭`, `이미지 별칭` -- 미등록 사용자는 질문 맨 뒤에 등록 이름을 붙일 수 있고, `이름등록 ABCD-EFGH`를 한 뒤에는 본인 조회에서 이름을 생략한다. +- 최대 16명 구성원과 기존 Quilo 로그인 세션 기반 접근 제어 +- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `파일 리스트`, `김종수T 학습지` +- 파일명·별칭이 정확히 일치하면 바로 보내고, 비슷하면 최대 3개 후보를 물은 뒤 `맞아` 또는 번호 응답으로 보낸다. - 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림 - 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다. -- 반 전체 자료와 본인 개인 자료만 노출하는 비공개 파일 저장소와 15분 열기·다운로드 링크 +- 반 전체 자료는 Google Drive, 개인 자료는 비공개 Supabase 저장소에 보관하고 15분 프록시 링크로만 연다. +- Google Drive 운영 계정은 기존 Quilo의 `구민준` 계정을 정확한 이름으로 찾아 연결한다. - 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림 - Event API 작업 결과 조회와 사용자별 성공·실패 기록 - 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그 @@ -44,7 +45,7 @@ npm run release:check ## 운영 배포 -1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v2 운영 DB는 [`db/migrations/003_member_timetable.sql`](./db/migrations/003_member_timetable.sql), [`db/migrations/004_portal_invite_channel.sql`](./db/migrations/004_portal_invite_channel.sql) 순서로 적용한다. +1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v4 운영 DB는 [`db/migrations/005_kakao_name_registration.sql`](./db/migrations/005_kakao_name_registration.sql)을 적용한다. 2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다. 3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다. 4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다. @@ -59,8 +60,8 @@ npm run release:check - 스킬 URL: `POST https://quilolab.com/schedule/api/kakao/skill?secret=CLASSBOT_KAKAO_SKILL_SECRET` - Event 이름: 관리자센터 값과 `KAKAO_EVENT_NAME`을 정확히 동일하게 설정 - Event API 활성화: 준비가 끝난 뒤 `KAKAO_EVENT_ENABLED=true` -- 최초 이름 등록: 관리자 화면에서 받은 일회용 코드를 챗봇에 `이름등록 ABCD-EFGH`로 입력 -- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `자료 목록`처럼 이름 없이 사용 +- 최초 이름 등록: 챗봇에 명단 이름 그대로 `이름 등록 구민준` 입력. 초대 코드는 필요 없다. +- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용 - 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력 Event API의 POST 성공은 접수 성공일 뿐 실제 전송 완료가 아니다. 이 서비스는 `taskId` 결과 조회 후에만 `sent`로 기록하며, 실패는 자동 재시도하지 않는다. 상세 운영 정책은 [`server/README.md`](./server/README.md), 공식 연동 근거와 열품타 정책은 [`docs/integrations.md`](./docs/integrations.md)를 참고한다. diff --git a/apps/classbot/db/migrations/005_kakao_name_registration.sql b/apps/classbot/db/migrations/005_kakao_name_registration.sql new file mode 100644 index 0000000..54888b6 --- /dev/null +++ b/apps/classbot/db/migrations/005_kakao_name_registration.sql @@ -0,0 +1,123 @@ +-- Classbot schema v5: exact roster-name Kakao registration and short-lived +-- pending file candidates used by the conversational file confirmation flow. + +create or replace function public.classbot_claim_member_by_name( + p_class_id uuid, + p_display_name text, + p_user_key text, + p_user_key_type text default 'botUserKey' +) +returns setof public.classbot_members +language plpgsql +security definer +set search_path = public +as $$ +declare + selected_class public.classbot_classes%rowtype; + selected_member public.classbot_members%rowtype; + matching_count integer; +begin + if coalesce(trim(p_display_name), '') = '' then + raise exception '명단의 이름을 정확히 입력해 주세요.'; + end if; + if char_length(trim(p_display_name)) > 40 then + raise exception '명단의 이름을 40자 이내로 정확히 입력해 주세요.'; + end if; + if coalesce(trim(p_user_key), '') = '' then + raise exception '카카오 사용자 식별값이 필요합니다.'; + end if; + if p_user_key_type not in ('botUserKey', 'plusfriendUserKey', 'appUserId') then + raise exception '올바른 카카오 사용자 식별값 유형이 아닙니다.'; + end if; + + select * into selected_class + from public.classbot_classes + where id = p_class_id + for update; + if not found then + raise exception '학급을 찾을 수 없습니다.'; + end if; + + select count(*) into matching_count + from public.classbot_members + where class_id = p_class_id + and status <> 'left' + and display_name = trim(p_display_name); + if matching_count = 0 then + raise exception '명단에서 이름을 찾을 수 없습니다. 이름을 정확히 입력해 주세요.'; + end if; + if matching_count > 1 then + raise exception '명단에 동명이인이 있어 이름만으로 등록할 수 없습니다.'; + end if; + + select * into selected_member + from public.classbot_members + where class_id = p_class_id + and status <> 'left' + and display_name = trim(p_display_name) + for update; + if selected_member.status = 'disabled' then + raise exception '비활성 구성원은 이름을 등록할 수 없습니다.'; + end if; + if exists ( + select 1 from public.classbot_members + where kakao_user_key = trim(p_user_key) + and id <> selected_member.id + ) then + raise exception '이미 다른 구성원으로 등록된 카카오 계정입니다.'; + end if; + if selected_member.kakao_user_key is not null + and selected_member.kakao_user_key <> trim(p_user_key) then + raise exception '이미 다른 카카오 계정에 등록된 이름입니다. 관리자에게 문의해 주세요.'; + end if; + if selected_member.kakao_user_key = trim(p_user_key) + and selected_member.status = 'active' then + return next selected_member; + return; + end if; + + update public.classbot_members + set kakao_user_key = trim(p_user_key), + kakao_user_key_type = p_user_key_type, + status = 'active', + joined_at = coalesce(joined_at, now()), + updated_at = now() + where class_id = p_class_id and id = selected_member.id + returning * into selected_member; + return next selected_member; +end; +$$; + +revoke all on function public.classbot_claim_member_by_name(uuid, text, text, text) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_claim_member_by_name(uuid, text, text, text) to service_role'; + end if; +end; +$$; + +create table if not exists public.classbot_kakao_states ( + member_id uuid primary key references public.classbot_members(id) on delete cascade, + class_id uuid not null references public.classbot_classes(id) on delete cascade, + pending_file_ids text[] not null check (cardinality(pending_file_ids) between 1 and 3), + pending_expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade +); + +create index if not exists classbot_kakao_states_expiry_idx + on public.classbot_kakao_states(class_id, pending_expires_at); + +drop trigger if exists set_updated_at on public.classbot_kakao_states; +create trigger set_updated_at +before update on public.classbot_kakao_states +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_kakao_states enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 5, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; diff --git a/apps/classbot/db/schema.sql b/apps/classbot/db/schema.sql index bd0dee6..b3410b8 100644 --- a/apps/classbot/db/schema.sql +++ b/apps/classbot/db/schema.sql @@ -7,7 +7,7 @@ create table if not exists public.classbot_schema_meta ( ); insert into public.classbot_schema_meta(id, version, applied_at) -values (1, 4, now()) +values (1, 5, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -245,6 +245,102 @@ begin end; $$; +create or replace function public.classbot_claim_member_by_name( + p_class_id uuid, + p_display_name text, + p_user_key text, + p_user_key_type text default 'botUserKey' +) +returns setof public.classbot_members +language plpgsql +security definer +set search_path = public +as $$ +declare + selected_class public.classbot_classes%rowtype; + selected_member public.classbot_members%rowtype; + matching_count integer; +begin + if coalesce(trim(p_display_name), '') = '' then + raise exception '명단의 이름을 정확히 입력해 주세요.'; + end if; + if char_length(trim(p_display_name)) > 40 then + raise exception '명단의 이름을 40자 이내로 정확히 입력해 주세요.'; + end if; + if coalesce(trim(p_user_key), '') = '' then + raise exception '카카오 사용자 식별값이 필요합니다.'; + end if; + if p_user_key_type not in ('botUserKey', 'plusfriendUserKey', 'appUserId') then + raise exception '올바른 카카오 사용자 식별값 유형이 아닙니다.'; + end if; + + select * into selected_class + from public.classbot_classes + where id = p_class_id + for update; + if not found then + raise exception '학급을 찾을 수 없습니다.'; + end if; + + select count(*) into matching_count + from public.classbot_members + where class_id = p_class_id + and status <> 'left' + and display_name = trim(p_display_name); + if matching_count = 0 then + raise exception '명단에서 이름을 찾을 수 없습니다. 이름을 정확히 입력해 주세요.'; + end if; + if matching_count > 1 then + raise exception '명단에 동명이인이 있어 이름만으로 등록할 수 없습니다.'; + end if; + + select * into selected_member + from public.classbot_members + where class_id = p_class_id + and status <> 'left' + and display_name = trim(p_display_name) + for update; + if selected_member.status = 'disabled' then + raise exception '비활성 구성원은 이름을 등록할 수 없습니다.'; + end if; + if exists ( + select 1 from public.classbot_members + where kakao_user_key = trim(p_user_key) + and id <> selected_member.id + ) then + raise exception '이미 다른 구성원으로 등록된 카카오 계정입니다.'; + end if; + if selected_member.kakao_user_key is not null + and selected_member.kakao_user_key <> trim(p_user_key) then + raise exception '이미 다른 카카오 계정에 등록된 이름입니다. 관리자에게 문의해 주세요.'; + end if; + if selected_member.kakao_user_key = trim(p_user_key) + and selected_member.status = 'active' then + return next selected_member; + return; + end if; + + update public.classbot_members + set kakao_user_key = trim(p_user_key), + kakao_user_key_type = p_user_key_type, + status = 'active', + joined_at = coalesce(joined_at, now()), + updated_at = now() + where class_id = p_class_id and id = selected_member.id + returning * into selected_member; + return next selected_member; +end; +$$; + +revoke all on function public.classbot_claim_member_by_name(uuid, text, text, text) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_claim_member_by_name(uuid, text, text, text) to service_role'; + end if; +end; +$$; + create table if not exists public.classbot_timetable ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -567,6 +663,20 @@ create unique index if not exists classbot_files_member_alias_idx create index if not exists classbot_files_list_idx on public.classbot_files(class_id, status, created_at desc); +create table if not exists public.classbot_kakao_states ( + member_id uuid primary key references public.classbot_members(id) on delete cascade, + class_id uuid not null references public.classbot_classes(id) on delete cascade, + pending_file_ids text[] not null check (cardinality(pending_file_ids) between 1 and 3), + pending_expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade +); + +create index if not exists classbot_kakao_states_expiry_idx + on public.classbot_kakao_states(class_id, pending_expires_at); + create table if not exists public.classbot_notifications ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -628,6 +738,7 @@ begin 'classbot_events', 'classbot_notices', 'classbot_files', + 'classbot_kakao_states', 'classbot_notifications' ] loop @@ -649,6 +760,7 @@ alter table public.classbot_member_timetable enable row level security; alter table public.classbot_events enable row level security; alter table public.classbot_notices enable row level security; alter table public.classbot_files enable row level security; +alter table public.classbot_kakao_states enable row level security; alter table public.classbot_notifications enable row level security; alter table public.classbot_audit_logs enable row level security; diff --git a/apps/classbot/docs/deployment.md b/apps/classbot/docs/deployment.md index 12d1831..42238dc 100644 --- a/apps/classbot/docs/deployment.md +++ b/apps/classbot/docs/deployment.md @@ -27,11 +27,12 @@ select to_regprocedure('public.classbot_health_check()') is not null as health_rpc, to_regprocedure('public.classbot_create_member(uuid,text,text)') is not null as member_rpc, to_regprocedure('public.classbot_claim_invite(uuid,text,text,text)') is not null as invite_rpc, + to_regprocedure('public.classbot_claim_member_by_name(uuid,text,text,text)') is not null as name_claim_rpc, to_regprocedure('public.classbot_replace_timetable_day(uuid,integer,jsonb)') is not null as timetable_rpc, to_regprocedure('public.classbot_replace_member_timetable(uuid,uuid,jsonb)') is not null as member_timetable_rpc; ``` -기대값은 schema version `4`와 모든 RPC의 `true`다. 기존 v2 운영 DB에는 전체 스키마 대신 [`003_member_timetable.sql`](../db/migrations/003_member_timetable.sql), [`004_portal_invite_channel.sql`](../db/migrations/004_portal_invite_channel.sql)을 순서대로 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. +기대값은 schema version `5`와 모든 RPC의 `true`다. 기존 v4 운영 DB에는 전체 스키마 대신 [`005_kakao_name_registration.sql`](../db/migrations/005_kakao_name_registration.sql)을 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. ## 3. 기존 Render 서비스 설정 @@ -41,6 +42,22 @@ select - `CLASSBOT_CRON_SECRET`: 32자 이상 - `CLASSBOT_KAKAO_SKILL_SECRET`: 32자 이상 +- `CLASSBOT_GOOGLE_DRIVE_OWNER_NAME`: Google Drive를 연결한 Quilo 관리자 계정 이름. 기본값은 `구민준` + +자료실은 기존 Quilo의 Google OAuth 연결과 `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `CLOUD_TOKEN_SECRET`을 그대로 재사용한다. 관리자가 Quilo에서 Google Drive를 먼저 연결하면 서버가 제한된 `drive.file` 권한으로 `Quilo schedule 자료실` 폴더를 자동 생성하고, 그 폴더의 PDF·이미지만 일정 사이트와 챗봇에 표시한다. 공개 공유 링크를 만들지 않으며, 파일은 기존 15분 HMAC 링크를 거쳐 서버가 프록시한다. + +반 전체 파일은 일정 사이트의 자료 업로드 화면을 통해 올려야 Google Drive 파일로 생성되어 동기화된다. `drive.file` 최소 권한 특성상 사용자가 Drive 화면에서 직접 만든 파일은 같은 폴더에 넣어도 Quilo 앱이 읽지 못할 수 있다. 개인 자료는 Drive에 올리지 않고 기존 비공개 Supabase 저장소에 유지한다. Drive 폴더 열기는 관리자에게만 제공하며 학생과 챗봇에는 Google 원본 링크를 노출하지 않는다. + +기본 폴더 이름은 `CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME=Quilo schedule 자료실`이다. `CLASSBOT_GOOGLE_DRIVE_FOLDER_ID`는 같은 Quilo OAuth 앱이 이미 생성해 접근 가능한 폴더를 지정할 때만 선택적으로 사용한다. 외부 Google Drive 앱이나 Connector가 만든 폴더 ID를 넣으면 `drive.file` 권한으로 접근할 수 없으므로 사용하지 않는다. + +배포 후 관리자 세션으로 아래를 확인한다. + +```text +GET /schedule/api/admin/drive/status +POST /schedule/api/admin/drive/sync +``` + +서버는 기본적으로 이름이 정확히 `구민준`인 Quilo 계정의 UUID를 내부에서 찾아 사용한다. 동명이인 등으로 이름 조회를 쓸 수 없을 때만 `CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID`를 override로 지정한다. `connected: true`가 아니면 owner 이름, Quilo의 Google 연결 상태, 세 OAuth 환경변수를 먼저 확인한다. Drive 연결이 실패해도 기존 Supabase 자료실은 계속 작동한다. `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `SESSION_SECRET`, `ADMIN_PASSWORD`는 기존 Quilo 값을 재사용한다. 기존 `ADMIN_PASSWORD`가 16자 미만이면 `CLASSBOT_ADMIN_PASSWORD`를 16자 이상으로 별도 설정한다. 각 secret은 서로 다른 값이어야 한다. 운영 관리 화면은 별도 classbot 쿠키나 비밀번호 로그인을 만들지 않고, 기존 Quilo 관리자 세션을 매 요청 다시 검증한다. @@ -69,9 +86,9 @@ Smoke test는 관리자 로그인을 시도하거나 데이터를 생성하지 2. 챗봇 생성·채널 연결과 봇 배포를 완료한다. 단톡방에서 직접 호출하는 조회 기능만 쓸 때는 사업자 인증이나 월렛이 필요하지 않다. 3. 카카오 스킬에 공개 HTTPS `/schedule/api/kakao/skill` 엔드포인트와 `X-Classbot-Skill-Secret` 헤더를 연결하고 폴백 블록이 스킬 응답을 사용하게 한다. 4. `KAKAO_BOT_ID=6a57ace9fd013545b6416293`을 설정하고, `KAKAO_REST_API_KEY`는 Render에 직접 입력한다. -5. 개발봇 또는 테스트 채널에서 `이름등록 초대코드`를 한 번 실행한 뒤 `오늘 일정`, `시간표 전체`, `자료 목록`을 이름 없이 조회하고 개인 일정·개인 자료 격리를 확인한다. +5. 개발봇 또는 테스트 채널에서 `이름 등록 구민준`처럼 명단의 정확한 이름을 한 번 입력한 뒤 `오늘 일정`, `시간표 전체`, `파일 리스트`를 이름 없이 조회하고 개인 일정·개인 자료 격리를 확인한다. -학생 웹 포털은 `https://quilolab.com/schedule/`에서 이름과 같은 일회용 초대 코드로 최초 로그인한다. 학생은 본인 일정만 추가할 수 있고 관리자 역할만 반 전체 공개 범위를 선택할 수 있는지 확인한다. +학생 웹 포털은 `https://quilolab.com/schedule/`에서 기존 Quilo 계정으로 로그인한다. 계정 이름이 16명 명단 중 한 명과 정확히 일치해야 열리며, 학생은 본인 일정만 추가할 수 있고 관리자 역할만 반 전체 공개 범위를 선택할 수 있는지 확인한다. 자동 알림을 추가로 켜는 경우에만 비즈니스 채널 인증, 비즈앱, 카카오 로그인, 월렛과 Event 블록을 준비하고 이벤트명을 `quilo_schedule_notification`으로 설정한다. 테스트 구성원 한 명만 활성 수신자로 둔 상태에서 `KAKAO_EVENT_ENABLED=true`로 바꾸고 한 건만 시험 발송한다. POST 접수 후 task 결과가 `sent`로 확정되면 운영 Cron을 활성화하고, 실패하면 즉시 `KAKAO_EVENT_ENABLED=false`로 되돌린다. @@ -85,6 +102,6 @@ Smoke test는 관리자 로그인을 시도하거나 데이터를 생성하지 - 관리자 세션이 반복 해제되거나 외부 Origin에서 관리자 API가 허용된다. - 동일 일정·공지·Cron 실행에서 중복 알림이 생긴다. - task 결과 조회 전 알림이 성공 처리된다. -- 데이터가 재배포 후 사라지거나 16명 정원·초대 코드 일회성 규칙이 깨진다. +- 데이터가 재배포 후 사라지거나 16명 정원·카카오 이름 1회 연결 규칙이 깨진다. 롤백 후에도 Supabase 스키마를 임의로 삭제하지 않는다. 먼저 `KAKAO_EVENT_ENABLED=false`로 전송을 정지하고 Render의 직전 정상 Deploy를 선택한다. diff --git a/apps/classbot/server/README.md b/apps/classbot/server/README.md index 149809a..1e28063 100644 --- a/apps/classbot/server/README.md +++ b/apps/classbot/server/README.md @@ -21,4 +21,4 @@ Kakao Event API의 POST `SUCCESS`는 접수 성공일 뿐 실제 발송 성공 구성원 생성, 반 시간표 요일 교체, 개인별 시간표 전체 교체는 Supabase RPC 안에서 대상 행을 잠그고 처리한다. 개인별 시간표 테이블은 RLS를 켜고 anon/authenticated 정책을 만들지 않는다. 관리자 API의 구성원 DTO와 audit JSON에는 `kakao_user_key` 원문을 반환하거나 기록하지 않는다. -학생 포털 최초 로그인은 등록 이름과 일회용 초대 코드를 함께 검증한다. 같은 초대 코드는 카카오 이름 등록과 웹 포털에서 채널별로 한 번씩만 사용할 수 있다. 성공 시 30일 HMAC HttpOnly 쿠키를 발급하며, 학생 요청의 `member_id`는 쿠키의 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. +학생 포털은 부모 Quilo 서버가 매 요청 새로 검증한 로그인 사용자의 이름을 16명 명단과 정확히 대조한다. 하위 앱에는 Quilo API 토큰을 넘기지 않으며, 학생 요청의 `member_id`는 일치한 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. 카카오 이름 등록은 `이름 등록 구민준`처럼 명단 이름을 정확히 사용하며, 한 번 다른 카카오 계정에 연결된 이름은 관리자가 초기화하기 전 재등록할 수 없다. diff --git a/apps/classbot/server/app.js b/apps/classbot/server/app.js index 8f3c6ed..fc8bb1e 100644 --- a/apps/classbot/server/app.js +++ b/apps/classbot/server/app.js @@ -9,6 +9,10 @@ import { loadConfig } from "./config.js"; import { createInviteCode, createCronGuard, hashInviteCode, requireAdmin, safeEqual } from "./security.js"; import { createStore } from "./store/index.js"; import { createFileToken, verifyFileToken } from "./services/file-tokens.js"; +import { + CompositeFileStore, + createGoogleDriveFileProvider, +} from "./services/google-drive-files.js"; import { createPortalToken, portalCookieOptions, @@ -297,6 +301,18 @@ export async function createApp(options = {}) { const kakaoClient = options.kakaoClient || new KakaoEventClient(config.kakao); const notifications = options.notificationService || new NotificationService({ store, kakaoClient }); const now = typeof options.now === "function" ? options.now : () => new Date(); + const googleDriveFiles = options.googleDriveFileProvider + || createGoogleDriveFileProvider(config, options.googleDriveDependencies); + const fileStore = options.fileStore || new CompositeFileStore(store, googleDriveFiles); + const commandStore = new Proxy(store, { + get(target, property) { + if (property === "listFiles") return fileStore.listFiles.bind(fileStore); + if (property === "getFile") return fileStore.getFile.bind(fileStore); + if (property === "downloadFile") return fileStore.downloadFile.bind(fileStore); + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); const embedded = options.embedded === true; const app = express(); const origins = allowedOrigins(config.allowedOrigin); @@ -307,6 +323,16 @@ export async function createApp(options = {}) { }; const setPortalCookie = (res, token) => res.cookie(PORTAL_COOKIE_NAME, token, portalCookie); const resolvePortalMember = async (req) => { + if (embedded) { + const identity = req.classbotExternalUser; + if (!identity?.id || typeof identity.name !== "string" || !identity.name) return null; + const members = await store.listMembers(); + const matches = members.filter((member) => ( + member.display_name === identity.name + && !new Set(["left", "disabled"]).has(member.status) + )); + return matches.length === 1 ? matches[0] : null; + } const token = readPortalCookie(req.get("cookie")); if (!token) return null; let verified; @@ -438,12 +464,15 @@ export async function createApp(options = {}) { } catch (error) { throw new HttpError(/만료/.test(error.message) ? 410 : 403, error.message); } - const file = await store.getFile(verified.fileId); + const file = await fileStore.getFile(verified.fileId); if (!file || file.status !== "active") throw new HttpError(404, "파일을 찾을 수 없습니다."); - sendFileBody(res, file, await store.downloadFile(file.id), { attachment: req.query.download === "1" }); + sendFileBody(res, file, await fileStore.downloadFile(file.id), { attachment: req.query.download === "1" }); })); app.post("/api/portal/login", createPortalLoginLimiter(), asyncRoute(async (req, res) => { + if (embedded) { + throw new HttpError(410, "별도 학급 로그인이 없어졌습니다. Quilo 계정으로 로그인해 주세요."); + } const displayName = typeof req.body?.display_name === "string" ? req.body.display_name.trim() : ""; const inviteCode = typeof req.body?.invite_code === "string" ? req.body.invite_code.trim() : ""; const members = displayName && displayName.length <= 40 && inviteCode && inviteCode.length <= 40 @@ -469,8 +498,14 @@ export async function createApp(options = {}) { app.get("/api/portal/session", asyncRoute(async (req, res) => { const member = await resolvePortalMember(req); if (!member) { - if (readPortalCookie(req.get("cookie"))) clearPortalCookie(res); - return res.json({ authenticated: false }); + if (!embedded && readPortalCookie(req.get("cookie"))) clearPortalCookie(res); + return res.json({ + authenticated: false, + ...(embedded ? { + reason: req.classbotExternalUser ? "roster_mismatch" : "login_required", + login_url: "/login.html?next=/schedule/", + } : {}), + }); } return res.json({ authenticated: true, member: portalMember(member) }); })); @@ -489,11 +524,12 @@ export async function createApp(options = {}) { } return store.listTimetable(); })(); - const [classroom, timetable, events, notices] = await Promise.all([ + const [classroom, timetable, events, notices, members] = await Promise.all([ store.getClassroom(), timetablePromise, store.listEvents({ ...range, targetMemberId: req.portalMember.id }), store.listNotices({ status: "published", limit: 50 }), + store.listMembers(), ]); res.json({ member: portalMember(req.portalMember), @@ -501,12 +537,15 @@ export async function createApp(options = {}) { timetable, notices: notices.filter((notice) => notice.status === "published"), events: events.filter((event) => event.member_id == null || event.member_id === req.portalMember.id), + members: members + .filter((member) => !new Set(["left", "disabled"]).has(member.status)) + .map(portalMember), }); })); app.get("/api/portal/files", requirePortal, asyncRoute(async (req, res) => { const issuedAt = now(); - const files = await store.listFiles({ targetMemberId: req.portalMember.id, status: "active" }); + const files = await fileStore.listFiles({ targetMemberId: req.portalMember.id, status: "active" }); res.json({ items: files .filter((file) => file.status === "active" && (file.member_id == null || file.member_id === req.portalMember.id)) @@ -556,7 +595,15 @@ export async function createApp(options = {}) { ? req.classbotExternalAdmin === true : req.session?.isAdmin === true; - app.get("/api/admin/session", (req, res) => res.json({ authenticated: isAdminRequest(req) })); + app.get("/api/admin/session", (req, res) => { + const authenticated = isAdminRequest(req); + return res.json({ + authenticated, + ...(authenticated && req.classbotExternalUser + ? { actor: { id: req.classbotExternalUser.id, name: req.classbotExternalUser.name } } + : {}), + }); + }); app.post("/api/admin/login", createLoginLimiter(), asyncRoute(async (req, res) => { if (embedded) throw new HttpError(401, "Quilo 관리자 계정으로 먼저 로그인해 주세요."); const password = requireString(req.body?.password, "관리자 비밀번호", 512); @@ -577,7 +624,21 @@ export async function createApp(options = {}) { }); app.get("/api/admin/files", asyncRoute(async (req, res) => { - res.json({ items: (await store.listFiles({ all: true })).map((file) => fileForAdmin(req, file)) }); + res.json({ items: (await fileStore.listFiles({ all: true })).map((file) => fileForAdmin(req, file)) }); + })); + app.get("/api/admin/drive/status", asyncRoute(async (_req, res) => { + res.json(await googleDriveFiles.status()); + })); + app.post("/api/admin/drive/sync", asyncRoute(async (req, res) => { + if (!googleDriveFiles.configured) { + throw new HttpError(409, "Google Drive 자료실 운영 계정이 설정되지 않았습니다."); + } + const items = await googleDriveFiles.listFiles(); + res.json({ + ok: true, + item_count: items.length, + items: items.map((file) => fileForAdmin(req, file)), + }); })); app.post("/api/admin/files", receiveFile, asyncRoute(async (req, res) => { if (!req.file?.buffer?.length) throw new HttpError(400, "올릴 PDF 또는 이미지 파일을 선택해 주세요."); @@ -594,7 +655,7 @@ export async function createApp(options = {}) { const member = await store.getMember(memberId); if (!member || member.status === "left") throw new HttpError(400, "개인 자료를 받을 구성원을 찾을 수 없습니다."); } - const item = await store.createFile({ + const item = await fileStore.createFile({ member_id: memberId, alias: requireString(req.body?.alias, "자료 별칭", 60), filename: requireString(req.file.originalname, "파일 이름", 180), @@ -608,15 +669,15 @@ export async function createApp(options = {}) { if (req.body.alias !== undefined) patch.alias = requireString(req.body.alias, "자료 별칭", 60); if (req.body.description !== undefined) patch.description = optionalString(req.body.description, "자료 설명", 1000); if (!Object.keys(patch).length) throw new HttpError(400, "변경할 자료 정보를 입력해 주세요."); - res.json({ item: fileForAdmin(req, await store.updateFile(req.params.id, patch, "admin")) }); + res.json({ item: fileForAdmin(req, await fileStore.updateFile(req.params.id, patch, "admin")) }); })); app.get("/api/admin/files/:id/download", asyncRoute(async (req, res) => { - const file = await store.getFile(req.params.id); + const file = await fileStore.getFile(req.params.id); if (!file || file.status !== "active") throw new HttpError(404, "파일을 찾을 수 없습니다."); - sendFileBody(res, file, await store.downloadFile(file.id), { attachment: true }); + sendFileBody(res, file, await fileStore.downloadFile(file.id), { attachment: true }); })); app.delete("/api/admin/files/:id", asyncRoute(async (req, res) => { - res.json({ item: publicFile(await store.deleteFile(req.params.id, "admin")) }); + res.json({ item: publicFile(await fileStore.deleteFile(req.params.id, "admin")) }); })); app.get("/api/admin/overview", asyncRoute(async (_req, res) => { @@ -816,7 +877,7 @@ export async function createApp(options = {}) { } res.json(await handleKakaoCommand({ payload: req.body || {}, - store, + store: commandStore, now: now(), makeFileUrl: (file) => fileLink(req, createFileToken(file.id, config.sessionSecret, { now: now() })), })); diff --git a/apps/classbot/server/config.js b/apps/classbot/server/config.js index 9e10c54..cdf2e32 100644 --- a/apps/classbot/server/config.js +++ b/apps/classbot/server/config.js @@ -58,6 +58,15 @@ export function loadConfig(overrides = {}) { eventName: env.KAKAO_EVENT_NAME || "quilo_schedule_notification", apiBase: (env.KAKAO_EVENT_API_BASE || "https://bot-api.kakao.com").replace(/\/$/, ""), }, + googleDrive: { + // The folder is created with the connected Quilo Google OAuth token so + // the intentionally narrow drive.file scope can access it. An explicit + // ID is only an override for a folder already created by the same app. + folderId: String(env.CLASSBOT_GOOGLE_DRIVE_FOLDER_ID || "").trim(), + folderName: String(env.CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME || "Quilo schedule 자료실").trim(), + ownerUserId: String(env.CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID || "").trim(), + ownerName: String(env.CLASSBOT_GOOGLE_DRIVE_OWNER_NAME || "구민준").trim(), + }, }; requiredInProduction(production, "CLASSBOT_SESSION_SECRET or SESSION_SECRET", config.sessionSecret, 32); @@ -105,6 +114,18 @@ export function loadConfig(overrides = {}) { if (config.kakao.enabled && (!config.kakao.botId || !config.kakao.restApiKey)) { throw new Error("Kakao Event API requires KAKAO_BOT_ID and KAKAO_REST_API_KEY when enabled"); } + if (config.googleDrive.folderId && !/^[A-Za-z0-9_-]{10,300}$/.test(config.googleDrive.folderId)) { + throw new Error("CLASSBOT_GOOGLE_DRIVE_FOLDER_ID is invalid"); + } + if (config.googleDrive.ownerUserId && !/^[A-Za-z0-9_-]{8,300}$/.test(config.googleDrive.ownerUserId)) { + throw new Error("CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID is invalid"); + } + if (!config.googleDrive.ownerName || config.googleDrive.ownerName.length > 40) { + throw new Error("CLASSBOT_GOOGLE_DRIVE_OWNER_NAME must be between 1 and 40 characters"); + } + if (!config.googleDrive.folderName || config.googleDrive.folderName.length > 100) { + throw new Error("CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME must be between 1 and 100 characters"); + } requireProductionHttpsUrl(production, "KAKAO_EVENT_API_BASE", config.kakao.apiBase); return config; diff --git a/apps/classbot/server/config.test.js b/apps/classbot/server/config.test.js index 2bbafa2..ae05772 100644 --- a/apps/classbot/server/config.test.js +++ b/apps/classbot/server/config.test.js @@ -73,3 +73,19 @@ test("기존 Quilo 서버의 Supabase·세션·관리자 환경변수를 그대 assert.equal(config.adminPassword, "existing-quilo-admin-password"); assert.equal(config.supabaseServiceRoleKey, "existing-service-key"); }); + +test("Google Drive 자료실은 Quilo 사용자와 앱 소유 폴더 override만 허용한다", () => { + const config = loadConfig({ + ...validProduction, + CLASSBOT_GOOGLE_DRIVE_OWNER_USER_ID: "11111111-2222-3333-4444-555555555555", + CLASSBOT_GOOGLE_DRIVE_FOLDER_NAME: "Quilo schedule 자료실", + }); + assert.equal(config.googleDrive.folderId, ""); + assert.equal(config.googleDrive.ownerUserId, "11111111-2222-3333-4444-555555555555"); + assert.equal(config.googleDrive.ownerName, "구민준"); + assert.equal(config.googleDrive.folderName, "Quilo schedule 자료실"); + assert.throws( + () => loadConfig({ ...validProduction, CLASSBOT_GOOGLE_DRIVE_FOLDER_ID: "invalid folder/id" }), + /FOLDER_ID is invalid/, + ); +}); diff --git a/apps/classbot/server/google-drive-app.test.js b/apps/classbot/server/google-drive-app.test.js new file mode 100644 index 0000000..85f4672 --- /dev/null +++ b/apps/classbot/server/google-drive-app.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { MemoryStore } from "./store/memory-store.js"; + +const config = { + nodeEnv: "test", + production: false, + port: 0, + allowedOrigin: "http://localhost:5173", + sessionSecret: "drive-app-test-session-secret-that-is-long-enough", + adminPassword: "correct horse battery staple", + cronSecret: "cron-test-secret", + kakaoSkillSecret: "", + storage: "memory", + classCode: "2-4", + className: "2학년 4반", + timezone: "Asia/Seoul", + kakao: { enabled: false, botId: "", restApiKey: "", eventName: "quilo_schedule_notification", apiBase: "https://bot-api.kakao.com" }, +}; + +test("Drive 자료도 기존 관리자·학생·HMAC 다운로드 권한 경계 안에서만 제공한다", async () => { + const store = new MemoryStore(config); + const driveFile = { + id: "gdrive_test-file.signature", + member_id: null, + alias: "김종수T 학습지", + filename: "worksheet.pdf", + description: "Google Drive 자료", + mime_type: "application/pdf", + size_bytes: 18, + provider: "google_drive", + status: "active", + created_at: "2026-07-16T00:00:00.000Z", + updated_at: "2026-07-16T00:00:00.000Z", + }; + const googleDriveFileProvider = { + configured: true, + isManagedId: (id) => id === driveFile.id, + listFiles: async () => [driveFile], + getFile: async (id) => id === driveFile.id ? driveFile : null, + downloadFile: async () => Buffer.from("%PDF-1.4\n%%EOF"), + uploadFile: async () => driveFile, + status: async () => ({ configured: true, connected: true, item_count: 1, folder_url: "https://drive.google.com/example" }), + }; + const app = await createApp({ + config, + store, + googleDriveFileProvider, + now: () => new Date("2026-07-16T03:00:00.000Z"), + }); + const agent = request.agent(app); + + assert.equal((await request(app).get("/api/admin/drive/status")).status, 401); + assert.equal((await agent.post("/api/admin/login").send({ password: config.adminPassword })).status, 200); + + const status = await agent.get("/api/admin/drive/status"); + assert.equal(status.status, 200); + assert.equal(status.body.connected, true); + + const listed = await agent.get("/api/admin/files"); + const item = listed.body.items.find((entry) => entry.id === driveFile.id); + assert.equal(item.provider, "google_drive"); + assert.match(item.share_url, /^http:/); + + const publicDownload = await request(app).get(new URL(item.share_url).pathname).buffer(true); + assert.equal(publicDownload.status, 200); + assert.match(publicDownload.headers["cache-control"], /private/); + assert.equal(publicDownload.headers["access-control-allow-origin"], undefined); + assert.equal(publicDownload.body.toString(), "%PDF-1.4\n%%EOF"); + + const synced = await agent.post("/api/admin/drive/sync"); + assert.equal(synced.status, 200); + assert.equal(synced.body.item_count, 1); +}); diff --git a/apps/classbot/server/integration-mount.test.js b/apps/classbot/server/integration-mount.test.js index c7a5b7a..f65bcd3 100644 --- a/apps/classbot/server/integration-mount.test.js +++ b/apps/classbot/server/integration-mount.test.js @@ -19,8 +19,14 @@ test("기존 Express 4 Quilo 서버의 /schedule namespace에서 API가 동작 }), }); const parent = express4(); + const student = (await child.locals.classbot.store.listMembers()).find((item) => item.role === "student"); parent.use("/schedule/api/admin", (req, _res, next) => { req.classbotExternalAdmin = true; + req.classbotExternalUser = { id: "quilo-admin", name: "구민준", isAdmin: true }; + next(); + }); + parent.use("/schedule/api/portal", (req, _res, next) => { + req.classbotExternalUser = { id: "quilo-student", name: student.display_name, isAdmin: false }; next(); }); parent.use("/schedule", child); @@ -44,25 +50,12 @@ test("기존 Express 4 Quilo 서버의 /schedule namespace에서 API가 동작 assert.equal(uploaded.status, 201); assert.match(new URL(uploaded.body.item.share_url).pathname, /^\/schedule\/api\/files\//); - const members = await request(parent).get("/schedule/api/admin/members"); - const student = members.body.items.find((item) => item.role === "student"); - const invite = await request(parent) - .post(`/schedule/api/admin/members/${student.id}/invite`) - .send({ expires_in_hours: 1 }); - assert.equal(invite.status, 201); - const portalAgent = request.agent(parent); - const portalLogin = await portalAgent - .post("/schedule/api/portal/login") - .send({ display_name: student.display_name, invite_code: invite.body.code }); - assert.equal(portalLogin.status, 200); - const portalCookie = portalLogin.headers["set-cookie"].find((item) => item.startsWith("classbot_portal=")); - assert.match(portalCookie, /; Path=\/schedule;/); - assert.match(portalCookie, /; HttpOnly/); - assert.match(portalCookie, /; SameSite=Lax/); - const portalSession = await portalAgent.get("/schedule/api/portal/session"); + const portalLogin = await request(parent).post("/schedule/api/portal/login").send({ display_name: student.display_name, invite_code: "unused" }); + assert.equal(portalLogin.status, 410); + const portalSession = await request(parent).get("/schedule/api/portal/session"); assert.equal(portalSession.body.authenticated, true); assert.equal(portalSession.body.member.id, student.id); - const portalFiles = await portalAgent.get("/schedule/api/portal/files"); + const portalFiles = await request(parent).get("/schedule/api/portal/files"); assert.equal(portalFiles.status, 200); assert.equal(portalFiles.body.items.length, 1); assert.match(new URL(portalFiles.body.items[0].open_url).pathname, /^\/schedule\/api\/files\//); @@ -79,4 +72,21 @@ test("기존 Express 4 Quilo 서버의 /schedule namespace에서 API가 동작 assert.equal(anonymousSession.headers["set-cookie"], undefined); const anonymousOverview = await request(anonymousParent).get("/schedule/api/admin/overview"); assert.equal(anonymousOverview.status, 401); + const anonymousPortal = await request(anonymousParent).get("/schedule/api/portal/session"); + assert.deepEqual(anonymousPortal.body, { + authenticated: false, + reason: "login_required", + login_url: "/login.html?next=/schedule/", + }); + + const mismatchedParent = express4(); + mismatchedParent.use("/schedule/api/portal", (req, _res, next) => { + req.classbotExternalUser = { id: "quilo-outsider", name: `${student.display_name}님`, isAdmin: false }; + next(); + }); + mismatchedParent.use("/schedule", child); + const mismatchedSession = await request(mismatchedParent).get("/schedule/api/portal/session"); + assert.equal(mismatchedSession.body.authenticated, false); + assert.equal(mismatchedSession.body.reason, "roster_mismatch"); + assert.equal((await request(mismatchedParent).get("/schedule/api/portal/overview")).status, 401); }); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index 45a75ee..9758fb6 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -40,7 +40,15 @@ function extractInviteCode(payload, command) { const params = payload.action?.params || {}; const explicit = parameterValue(params.inviteCode || params.invite_code || params.code).trim(); if (explicit) return explicit; - return command.match(/(?:가입|초대|이름등록)\s*([A-Z0-9]{4}-?[A-Z0-9]{4})/i)?.[1] || ""; + return command.match(/(?:가입|초대)\s*([A-Z0-9]{4}-?[A-Z0-9]{4})/i)?.[1] || ""; +} + +function readNameRegistration(payload, command) { + const params = payload.action?.params || {}; + const explicit = parameterValue(params.displayName || params.display_name || params.memberName || params.member_name).trim(); + const match = normalizedText(command).match(/^이름\s*등록(?:\s+(.+))?$/u); + if (!match) return null; + return normalizedText(explicit || match[1] || ""); } function normalizedText(value) { @@ -54,15 +62,22 @@ function compactText(value) { return normalizedText(value).replace(/\s+/g, "").toLowerCase(); } +function compactFileText(value) { + return normalizedText(value) + .normalize("NFKC") + .toLocaleLowerCase("ko") + .replace(/[\p{P}\p{S}\s]+/gu, ""); +} + function looksLikeFileCommand(command) { - return /^(?:자료\s*(?:목록|리스트)|파일(?:\s|$)|pdf(?:\s|$)|이미지(?:\s|$))/iu.test(normalizedText(command)); + return /^(?:자료\s*(?:목록|리스트)|파일\s*(?:목록|리스트)|파일(?:\s|$)|pdf(?:\s|$)|이미지(?:\s|$))/iu.test(normalizedText(command)); } -function readFileCommand(command) { +function readFileCommand(command, { allowRaw = false } = {}) { const text = normalizedText(command); - if (/^자료\s*(?:목록|리스트)$/u.test(text)) return { kind: "list" }; + if (/^(?:자료|파일)\s*(?:목록|리스트)$/u.test(text)) return { kind: "list" }; const match = text.match(/^(파일|pdf|이미지)\s+(.+)$/iu); - if (!match) return null; + if (!match) return allowRaw && text ? { kind: "open", requestedType: "file", alias: text } : null; const requestedType = match[1].toLowerCase() === "pdf" ? "pdf" : match[1] === "이미지" @@ -145,24 +160,36 @@ function noticeText(notices) { .join("\n\n"); } -function helpText() { +function helpText({ registered = false, displayName = "" } = {}) { + const registration = registered + ? `${displayName || "구성원"}님으로 등록되어 있어요. 이제 명령 뒤에 이름을 붙이지 않아도 됩니다.` + : "먼저 명단의 이름 그대로 ‘이름 등록 구민준’처럼 입력해 주세요. 초대 코드는 필요하지 않습니다."; return [ - "Quilo에서 이렇게 물어보세요.", - "최초 1회 관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’를 입력해 주세요.", - "등록 후 본인 일정·시간표·자료 조회에는 이름을 붙이지 않아도 됩니다.", + "Quilo schedule 사용법", + registration, + "", + "일정·시간표", "• 오늘 일정 / 내일 일정 / 다음 일정", "• 이번 주 남은 일정 / 이번 달 일정", "• 수행평가 과제 통합 요약 / 다음 주 시험", "• 오늘 시간표 / 시간표 전체", - "• 자료 목록 / PDF 가정통신문 / 이미지 좌석표", - "다른 구성원의 일정은 기존처럼 질문 맨 뒤에 등록 이름을 붙여 조회할 수 있어요.", - "자료는 카카오 가입이 완료된 본인만 열 수 있어요.", - "• 공지", - "• 이름등록 ABCD-EFGH (기존 ‘가입 ABCD-EFGH’도 가능)", + "", + "공지·자료·알림", + "• 공지 / 파일 리스트", + "• 파일 좌석표 / PDF 가정통신문 / 이미지 좌석표", + "• 파일명이나 별칭을 바로 입력해도 됩니다. 비슷한 파일이면 후보를 확인해 드려요.", "• 알림 설정 / 알림 켜기 / 알림 끄기", + "", + "다른 구성원의 반 전체 일정은 질문 맨 뒤에 정확한 이름을 붙여 조회할 수 있어요.", ].join("\n"); } +function helpQuickReplies(registered = false) { + if (registered) return registeredQuickReplies(); + return ["도움말", "공지", "오늘 일정", "시간표 전체", "파일 리스트"] + .map((messageText) => ({ label: messageText, action: "message", messageText })); +} + function eventKind(intent) { if (intent === "assessment") return { label: "수행평가", category: "assessment", empty: "등록된 수행평가가 없습니다." }; if (intent === "assignment") return { label: "과제", category: "assignment", empty: "등록된 과제가 없습니다." }; @@ -220,28 +247,51 @@ function fileListText(files, displayName) { return `${displayName}님의 자료 목록\n\n${rows.join("\n")}`; } -async function answerFileQuery({ command, requester, store, makeFileUrl, quickReplies }) { - const replies = quickReplies || registeredQuickReplies(); - if (typeof store.listFiles !== "function") { - return simpleTextResponse("현재 자료 조회 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); +function fileMatchesType(file, requestedType) { + return requestedType === "file" + || (requestedType === "pdf" && isPdfFile(file)) + || (requestedType === "image" && isImageFile(file)); +} + +function fileLookupKeys(file) { + return [...new Set([file.alias, file.filename].map(compactFileText).filter(Boolean))]; +} + +function bigrams(value) { + if (value.length < 2) return new Set(value ? [value] : []); + return new Set(Array.from({ length: value.length - 1 }, (_, index) => value.slice(index, index + 2))); +} + +function similarityScore(query, candidate) { + if (!query || !candidate) return 0; + if (query === candidate) return 1; + if (candidate.includes(query) || query.includes(candidate)) { + return 0.62 + (Math.min(query.length, candidate.length) / Math.max(query.length, candidate.length)) * 0.3; } - const spec = readFileCommand(command); - if (!spec) return simpleTextResponse("‘자료 목록 이름’ 또는 ‘파일 별칭 이름’처럼 입력해 주세요.", replies); + const left = bigrams(query); + const right = bigrams(candidate); + let overlap = 0; + for (const token of left) if (right.has(token)) overlap += 1; + return left.size + right.size ? (2 * overlap) / (left.size + right.size) : 0; +} - const files = availableFiles(await store.listFiles({ targetMemberId: requester.id }), requester.id); - if (spec.kind === "list") return simpleTextResponse(fileListText(files, requester.display_name), replies); +function rankFileCandidates(files, query, requestedType) { + const compactQuery = compactFileText(query); + return files + .filter((file) => fileMatchesType(file, requestedType)) + .map((file) => ({ + file, + score: Math.max(0, ...fileLookupKeys(file).map((key) => similarityScore(compactQuery, key))), + })) + .filter((item) => item.score >= 0.34) + .sort((a, b) => b.score - a.score || String(a.file.alias || a.file.filename).localeCompare(String(b.file.alias || b.file.filename), "ko")) + .slice(0, 3); +} - const alias = normalizedText(spec.alias).toLowerCase(); - const matches = files.filter((file) => normalizedText(file.alias || file.filename).toLowerCase() === alias); - const typedMatches = matches.filter((file) => spec.requestedType === "file" - || (spec.requestedType === "pdf" && isPdfFile(file)) - || (spec.requestedType === "image" && isImageFile(file))); - if (typedMatches.length !== 1) return simpleTextResponse("요청한 자료를 찾을 수 없습니다. ‘자료 목록’에서 별칭을 확인해 주세요.", replies); +async function respondWithFile({ file, makeFileUrl, replies }) { if (typeof makeFileUrl !== "function") { return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); } - - const file = typedMatches[0]; let url; try { url = await makeFileUrl(file); @@ -268,6 +318,79 @@ async function answerFileQuery({ command, requester, store, makeFileUrl, quickRe } } +function candidateQuickReplies(candidates) { + return [ + { label: "맞아요", action: "message", messageText: "맞아요" }, + ...candidates.map(({ file }, index) => { + const name = String(file.alias || file.filename || `후보 ${index + 1}`); + return { label: `${index + 1}. ${name}`.slice(0, 14), action: "message", messageText: `${index + 1}번` }; + }), + { label: "파일 리스트", action: "message", messageText: "파일 리스트" }, + ]; +} + +async function rememberFileCandidates(store, requester, candidates) { + if (typeof store.setPendingFileSelection !== "function") return; + await store.setPendingFileSelection({ + memberId: requester.id, + fileIds: candidates.map(({ file }) => file.id), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }); +} + +async function answerFileQuery({ command, requester, store, makeFileUrl, quickReplies, allowRaw = false }) { + const replies = quickReplies || registeredQuickReplies(); + if (typeof store.listFiles !== "function") { + return simpleTextResponse("현재 자료 조회 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + const spec = readFileCommand(command, { allowRaw }); + if (!spec) return allowRaw ? null : simpleTextResponse("‘파일 리스트’ 또는 ‘파일 별칭’처럼 입력해 주세요.", replies); + + const files = availableFiles(await store.listFiles({ targetMemberId: requester.id }), requester.id); + if (spec.kind === "list") return simpleTextResponse(fileListText(files, requester.display_name), replies); + + const query = compactFileText(spec.alias); + const exactMatches = files.filter((file) => ( + fileMatchesType(file, spec.requestedType) && fileLookupKeys(file).includes(query) + )); + if (exactMatches.length === 1) { + if (typeof store.clearPendingFileSelection === "function") await store.clearPendingFileSelection(requester.id); + return respondWithFile({ file: exactMatches[0], makeFileUrl, replies }); + } + const candidates = rankFileCandidates(files, spec.alias, spec.requestedType); + if (!candidates.length) { + return allowRaw ? null : simpleTextResponse("요청한 자료를 찾을 수 없습니다. ‘파일 리스트’에서 파일명이나 별칭을 확인해 주세요.", replies); + } + await rememberFileCandidates(store, requester, candidates); + const names = candidates.map(({ file }, index) => `${index + 1}. ${file.alias || file.filename}`).join("\n"); + return simpleTextResponse( + `혹시 ‘${candidates[0].file.alias || candidates[0].file.filename}’ 파일이 맞나요?\n\n비슷한 후보\n${names}\n\n맞으면 ‘맞아’, 아니면 아래 후보나 ‘파일 리스트’를 눌러 주세요.`, + candidateQuickReplies(candidates), + ); +} + +const FILE_CONFIRM_YES = new Set(["맞아", "맞아요", "맞습니다", "네", "넵", "예", "응", "ㅇㅇ"]); +const FILE_CONFIRM_NO = new Set(["아니", "아니야", "아니요", "ㄴㄴ"]); + +async function answerPendingFileConfirmation({ command, requester, store, makeFileUrl }) { + const normalized = compactText(command); + const ordinal = normalized.match(/^([123])(?:번|번째)?$/)?.[1]; + if (!FILE_CONFIRM_YES.has(normalized) && !FILE_CONFIRM_NO.has(normalized) && !ordinal) return null; + if (typeof store.getPendingFileSelection !== "function") return null; + const pending = await store.getPendingFileSelection(requester.id); + if (!pending?.file_ids?.length) return null; + if (FILE_CONFIRM_NO.has(normalized)) { + if (typeof store.clearPendingFileSelection === "function") await store.clearPendingFileSelection(requester.id); + return simpleTextResponse("알겠습니다. ‘파일 리스트’를 눌러 정확한 파일명이나 별칭을 확인해 주세요.", registeredQuickReplies()); + } + const selectedId = pending.file_ids[ordinal ? Number(ordinal) - 1 : 0]; + const files = availableFiles(await store.listFiles({ targetMemberId: requester.id }), requester.id); + const file = files.find((item) => item.id === selectedId); + if (typeof store.clearPendingFileSelection === "function") await store.clearPendingFileSelection(requester.id); + if (!file) return simpleTextResponse("후보 파일이 만료되었거나 더 이상 사용할 수 없습니다. ‘파일 리스트’를 다시 확인해 주세요.", registeredQuickReplies()); + return respondWithFile({ file, makeFileUrl, replies: registeredQuickReplies() }); +} + async function answerReadQuery({ command, intent, member, store, now, quickReplies, privateAccess = false }) { const period = readPeriod(command, intent); const replies = quickReplies || personalizedQuickReplies(member.display_name); @@ -358,10 +481,25 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak }; try { - if (/^(가입|초대|이름등록)/.test(command)) { + const registrationName = readNameRegistration(payload, command); + if (registrationName !== null) { + if (!user) return simpleTextResponse("카카오 사용자 식별값을 확인할 수 없어 이름을 등록할 수 없습니다.", helpQuickReplies()); + if (!registrationName) return simpleTextResponse("명단의 이름을 정확히 입력해 주세요. 예: ‘이름 등록 구민준’", helpQuickReplies()); + if (registrationName.length > 40) return simpleTextResponse("명단의 이름을 40자 이내로 정확히 입력해 주세요.", helpQuickReplies()); + if (typeof store.claimMemberByName !== "function") return simpleTextResponse("현재 이름 등록 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", helpQuickReplies()); + const member = await store.claimMemberByName({ displayName: registrationName, userKey: user.id, userKeyType: user.type }); + const isFriend = String(payload.userRequest?.user?.properties?.isFriend ?? "").toLowerCase(); + const friendGuide = isFriend === "false" ? "\n알림을 받으려면 이 카카오톡 채널을 친구 추가해 주세요." : ""; + return simpleTextResponse( + `${member.display_name}님, 이름 등록이 완료되었습니다.\n이제 ‘오늘 일정’, ‘시간표 전체’, ‘파일 리스트’처럼 이름 없이 바로 물어보세요.${friendGuide}\n\n‘도움말’을 보내면 전체 사용법을 다시 볼 수 있습니다.`, + registeredQuickReplies(), + ); + } + + if (/^(가입|초대)/.test(command)) { const code = extractInviteCode(payload, command); if (!user) return simpleTextResponse("카카오 사용자 식별값을 확인할 수 없어 가입할 수 없습니다."); - if (!code) return simpleTextResponse("관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’처럼 입력해 주세요. 기존 ‘가입’ 명령도 사용할 수 있습니다."); + if (!code) return simpleTextResponse("이름 등록에는 초대 코드가 필요하지 않습니다. ‘이름 등록 구민준’처럼 명단의 이름을 입력해 주세요.", helpQuickReplies()); const member = await store.claimInvite({ code, userKey: user.id, userKeyType: user.type }); const isFriend = String(payload.userRequest?.user?.properties?.isFriend ?? "").toLowerCase(); const friendGuide = isFriend === "false" ? "\n알림을 받으려면 이 카카오톡 채널을 친구 추가해 주세요." : ""; @@ -371,12 +509,32 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak ); } + if (!normalized || new Set(["시작", "처음", "도움말", "사용법", "안녕", "안녕하세요", "뭐할수있어", "뭘할수있어"]).has(normalized)) { + const requester = await getRequester(); + const registered = requester?.status === "active"; + return simpleTextResponse( + helpText({ registered, displayName: requester?.display_name }), + helpQuickReplies(registered), + ); + } + + const confirmingRequester = await getRequester(); + if (confirmingRequester?.status === "active") { + const confirmation = await answerPendingFileConfirmation({ + command, + requester: confirmingRequester, + store, + makeFileUrl, + }); + if (confirmation) return confirmation; + } + if (looksLikeFileCommand(command)) { const requester = await getRequester(); const text = normalizedText(command); const requesterName = normalizedText(requester?.display_name); if (!requester || requester.status !== "active" || !requesterName) { - return simpleTextResponse("자료를 조회할 권한이 없습니다. 먼저 관리자에게 받은 코드로 이름등록을 완료해 주세요."); + return simpleTextResponse("자료를 조회할 권한이 없습니다. 먼저 ‘이름 등록 구민준’처럼 명단의 이름으로 등록해 주세요.", helpQuickReplies()); } const registered = (await store.listMembers()).filter((member) => member.status !== "left"); const trailingMatches = registered.filter((member) => { @@ -416,7 +574,7 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak const member = await getRequester(); const requiresMembership = normalized.includes("알림") || normalized.includes("공지"); if (requiresMembership && !member) { - return simpleTextResponse("2학년 4반 구성원만 조회할 수 있습니다. 먼저 ‘가입 초대코드’를 입력해 주세요."); + return simpleTextResponse("2학년 4반 구성원만 조회할 수 있습니다. 먼저 ‘이름 등록 구민준’처럼 명단의 이름으로 등록해 주세요.", helpQuickReplies()); } const memberReplies = member ? registeredQuickReplies() : undefined; @@ -448,9 +606,24 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak return simpleTextResponse(`2학년 4반 공지\n\n${noticeText(notices)}`, memberReplies); } - return simpleTextResponse(helpText()); + if (member?.status === "active") { + const fileResponse = await answerFileQuery({ + command, + requester: member, + store, + makeFileUrl, + quickReplies: registeredQuickReplies(), + allowRaw: true, + }); + if (fileResponse) return fileResponse; + } + + return simpleTextResponse( + helpText({ registered: member?.status === "active", displayName: member?.display_name }), + helpQuickReplies(member?.status === "active"), + ); } catch (error) { - const friendly = /초대 코드|이미 다른 구성원|이미 가입|학급 정원|찾을 수 없습니다|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤/.test(error.message) + const friendly = /초대 코드|이미 다른 구성원|이미 가입|이미 등록|다른 카카오|학급 정원|찾을 수 없습니다|명단|정확|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤|비활성/.test(error.message) ? error.message : "잠시 후 다시 시도해 주세요."; const replies = targetQuickReplies || (targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined); diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index 22b1b44..0fc8796 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -88,7 +88,7 @@ test("가입된 본인은 파일 명령에서 이름 suffix를 생략하고 이 "다음 일정", "수행평가 과제 통합 요약", "시간표 전체", - "자료 목록", + "파일 리스트", ]); const privateImage = await ask(store, "이미지 개인피드백", { userId: "joined-hong", makeFileUrl }); @@ -210,41 +210,37 @@ test("이번 주 남은 일정은 현재 시각 이후 일정만 유지한다", assert.doesNotMatch(text(response), /오늘 지난 일정|다음 주 일정/); }); -test("도움말은 최초 이름등록, 이름 없는 본인 조회와 명시적 타인 일정 조회를 안내한다", async () => { +test("첫 인사와 도움말은 코드 없는 이름 등록, 파일 후보 확인과 주요 quick reply를 안내한다", async () => { const store = fixture(); const help = await ask(store, "도움말"); - assert.match(text(help), /이름등록 ABCD-EFGH/); - assert.match(text(help), /본인 일정.*이름을 붙이지 않아도/); + assert.match(text(help), /이름 등록 구민준/); + assert.match(text(help), /초대 코드는 필요하지 않습니다/); assert.match(text(help), /다음 일정/); assert.match(text(help), /이번 달 일정/); assert.match(text(help), /수행평가 과제 통합 요약/); assert.match(text(help), /시간표 전체/); - assert.match(text(help), /자료 목록/); - assert.match(text(help), /다른 구성원의 일정.*등록 이름/); + assert.match(text(help), /파일 리스트/); + assert.match(text(help), /후보를 확인/); + assert.equal(help.template.quickReplies.some((item) => item.messageText === "파일 리스트"), true); + + const greeting = await ask(store, "안녕하세요"); + assert.match(text(greeting), /Quilo schedule 사용법/); + assert.equal(greeting.template.quickReplies.length, 5); const response = await ask(store, "이번 달 일정 홍길동"); assert.equal(response.template.quickReplies.length, 5); assert.equal(response.template.quickReplies.every((item) => item.messageText.endsWith("홍길동")), true); }); -test("이름등록은 기존 가입과 같은 초대코드 매핑을 사용하고 이후 이름 없이 본인 일정을 조회한다", async () => { +test("이름 등록은 명단의 정확한 이름을 현재 Kakao key에 묶고 이후 이름 없이 본인 일정을 조회한다", async () => { const store = fixture(); const member = store.members[0]; - let claimInput; - store.claimInvite = async (input) => { - claimInput = input; - member.status = "active"; - member.kakao_user_key = input.userKey; - member.kakao_user_key_type = input.userKeyType; - return { ...member }; - }; await store.createEvent({ title: "반 전체 일정", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: member.id, title: "홍길동 개인 일정", due_at: "2026-07-15T17:00:00" }); - const registration = await ask(store, "이름등록 ABCD-EFGH", { userId: "new-hong-user" }); - assert.match(text(registration), /이름 등록.*가입이 완료/); - assert.equal(claimInput.code, "ABCD-EFGH"); - assert.equal(claimInput.userKey, "new-hong-user"); + const registration = await ask(store, "이름 등록 홍길동", { userId: "new-hong-user" }); + assert.match(text(registration), /홍길동님, 이름 등록이 완료/); + assert.equal(store.members[0].kakao_user_key, "new-hong-user"); assert.equal(registration.template.quickReplies[0].messageText, "오늘 일정"); const response = await ask(store, "오늘 일정", { userId: "new-hong-user" }); @@ -254,6 +250,48 @@ test("이름등록은 기존 가입과 같은 초대코드 매핑을 사용하 assert.equal(response.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); }); +test("이름 등록은 없는 이름·동명이인·다른 key의 이름 탈취·한 key의 재바인딩을 거절한다", async () => { + const store = fixture(); + assert.match(text(await ask(store, "이름 등록 없는학생", { userId: "new-user" })), /명단에서.*찾을 수 없습니다/); + + await ask(store, "이름 등록 홍길동", { userId: "hong-key" }); + assert.match(text(await ask(store, "이름 등록 홍길동", { userId: "attacker-key" })), /이미 다른 카카오 계정/); + assert.match(text(await ask(store, "이름 등록 김학생", { userId: "hong-key" })), /이미 다른 구성원/); + + const duplicateStore = fixture(); + duplicateStore.members[2].display_name = "홍길동"; + assert.match(text(await ask(duplicateStore, "이름 등록 홍길동", { userId: "new-user" })), /동명이인/); +}); + +test("정확한 파일명·별칭은 바로 열고 오타는 후보 확인 뒤 네/응으로 첫 후보를 연다", async () => { + const store = fileFixture(); + store.files.push( + { id: "worksheet-main", alias: "김종수T 학습지", filename: "김종수T_학습지.pdf", description: "수업 자료", mime_type: "application/pdf", size_bytes: 2048, member_id: null, status: "active" }, + { id: "worksheet-answer", alias: "김종수T 학습지 정답", filename: "김종수T_학습지_정답.pdf", description: "정답", mime_type: "application/pdf", size_bytes: 2048, member_id: null, status: "active" }, + ); + const makeFileUrl = async (file) => `https://files.example.test/${file.id}`; + + const exact = await ask(store, "김종수T 학습지", { userId: "joined-hong", makeFileUrl }); + assert.equal(exact.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/worksheet-main"); + + const suggested = await ask(store, "김종수T 학습", { userId: "joined-hong", makeFileUrl }); + assert.match(text(suggested), /이게 맞나요|맞나요/); + assert.match(text(suggested), /김종수T 학습지/); + assert.equal(suggested.template.quickReplies.some((item) => item.messageText === "맞아요"), true); + + const confirmed = await ask(store, "응", { userId: "joined-hong", makeFileUrl }); + assert.equal(confirmed.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/worksheet-main"); + assert.equal(await store.getPendingFileSelection(store.members[0].id), null); +}); + +test("파일 리스트 명령은 자료 목록과 같은 범위의 파일명을 보여준다", async () => { + const store = fileFixture(); + const response = await ask(store, "파일 리스트", { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의 자료 목록/); + assert.match(text(response), /좌석표|가정통신문/); + assert.doesNotMatch(text(response), /김학생자료/); +}); + test("active 요청자는 본인 개인 조회를 이름 없이 쓰고 다른 이름 조회에서는 반 일정만 본다", async () => { const store = fileFixture(); await store.createEvent({ title: "오늘 반 일정", due_at: "2026-07-15T16:00:00" }); @@ -278,7 +316,7 @@ test("active 요청자는 본인 개인 조회를 이름 없이 쓰고 다른 "다음 일정", "수행평가 과제 통합 요약", "시간표 전체", - "자료 목록", + "파일 리스트", ]); } @@ -297,7 +335,7 @@ test("등록된 요청자의 알림·공지 응답 Quick Reply에도 이름 suff "다음 일정", "수행평가 과제 통합 요약", "시간표 전체", - "자료 목록", + "파일 리스트", ]); const notices = await ask(store, "공지", { userId: "joined-hong" }); diff --git a/apps/classbot/server/services/google-drive-files.js b/apps/classbot/server/services/google-drive-files.js new file mode 100644 index 0000000..01bf16b --- /dev/null +++ b/apps/classbot/server/services/google-drive-files.js @@ -0,0 +1,319 @@ +import crypto from "node:crypto"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const DRIVE_ID_PREFIX = "gdrive_"; +const MAX_FILE_BYTES = 20 * 1024 * 1024; +const ALLOWED_MIME_TYPES = new Set([ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +function defaultDependencies() { + const cloud = require("../../../../lib/cloud/oauth-providers.js"); + const supa = require("../../../../lib/supabase.js"); + return { + findUserByName: (name) => supa.findUserByName(name), + getCloudConnection: (userId, provider) => supa.getCloudConnection(userId, provider), + decryptToken: (value) => cloud.decryptToken(value), + getAccessToken: (refreshToken) => cloud.googleAccessToken(refreshToken), + listDriveFiles: (accessToken, options) => cloud.listDriveFiles(accessToken, options), + ensureDriveFolder: (accessToken, options) => cloud.ensureDriveFolder(accessToken, options), + getDriveFile: (accessToken, fileId) => cloud.getDriveFile(accessToken, fileId), + downloadDriveFile: (accessToken, fileId) => cloud.downloadDriveFile(accessToken, fileId), + uploadDriveFile: (accessToken, options) => cloud.uploadDriveFile(accessToken, options), + }; +} + +function safeIdentifier(value, label) { + const text = String(value || "").trim(); + if (!text || text.length > 300 || !/^[A-Za-z0-9_-]+$/.test(text)) { + throw new Error(`${label} 설정이 올바르지 않습니다.`); + } + return text; +} + +function normalizedFile(file) { + const mimeType = String(file?.mimeType || "").toLowerCase(); + const sizeBytes = Number(file?.size); + if (!file?.id || !file?.name || !ALLOWED_MIME_TYPES.has(mimeType)) return null; + if (file.capabilities?.canDownload === false) return null; + if (Number.isFinite(sizeBytes) && (sizeBytes < 1 || sizeBytes > MAX_FILE_BYTES)) return null; + return { + sourceId: String(file.id), + filename: String(file.name).slice(0, 180), + alias: String(file.appProperties?.classbotAlias || file.name.replace(/\.[^.]+$/, "")).trim().slice(0, 60), + description: String(file.appProperties?.classbotDescription || "Google Drive 자료").trim().slice(0, 1000), + mimeType, + sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : null, + createdAt: file.createdTime || file.modifiedTime || new Date(0).toISOString(), + modifiedAt: file.modifiedTime || file.createdTime || new Date(0).toISOString(), + }; +} + +function hmac(value, secret) { + return crypto.createHmac("sha256", secret).update(value).digest("base64url"); +} + +export class GoogleDriveFileProvider { + constructor({ folderId = "", folderName = "Quilo schedule 자료실", ownerUserId = "", ownerName = "구민준", secret = "", dependencies } = {}) { + this.folderId = String(folderId || "").trim(); + this.folderName = String(folderName || "Quilo schedule 자료실").trim().slice(0, 100) || "Quilo schedule 자료실"; + this.ownerUserId = String(ownerUserId || "").trim(); + this.ownerName = String(ownerName || "").trim(); + this.secret = String(secret || ""); + this.dependencies = dependencies || null; + this.lastError = null; + this.folderPromise = null; + this.folderWebViewLink = ""; + } + + get configured() { + return Boolean((this.ownerUserId || this.ownerName) && this.secret); + } + + async resolveOwnerUserId(dependencies) { + if (this.ownerUserId) return this.ownerUserId; + if (!this.ownerName || typeof dependencies.findUserByName !== "function") { + throw new Error("Google Drive 자료실 운영 계정을 찾을 수 없습니다."); + } + const owner = await dependencies.findUserByName(this.ownerName); + if (!owner?.id || owner.name !== this.ownerName) { + throw new Error("Google Drive 자료실 운영 계정을 찾을 수 없습니다."); + } + this.ownerUserId = safeIdentifier(owner.id, "Google Drive 운영 계정"); + return this.ownerUserId; + } + + isManagedId(value) { + return String(value || "").startsWith(DRIVE_ID_PREFIX); + } + + encodeId(sourceId) { + const id = safeIdentifier(sourceId, "Google Drive 파일"); + const encoded = Buffer.from(id, "utf8").toString("base64url"); + return `${DRIVE_ID_PREFIX}${encoded}.${hmac(`${this.ownerUserId}:${this.folderId}:${id}`, this.secret)}`; + } + + decodeId(value) { + const [encoded, supplied, extra] = String(value || "").slice(DRIVE_ID_PREFIX.length).split("."); + if (!this.isManagedId(value) || !encoded || !supplied || extra) throw new Error("올바르지 않은 Google Drive 자료입니다."); + let sourceId; + try { + sourceId = safeIdentifier(Buffer.from(encoded, "base64url").toString("utf8"), "Google Drive 파일"); + } catch { + throw new Error("올바르지 않은 Google Drive 자료입니다."); + } + const expected = hmac(`${this.ownerUserId}:${this.folderId}:${sourceId}`, this.secret); + const left = Buffer.from(supplied); + const right = Buffer.from(expected); + if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) { + throw new Error("올바르지 않은 Google Drive 자료입니다."); + } + return sourceId; + } + + async context() { + const dependencies = this.dependencies || defaultDependencies(); + const ownerUserId = await this.resolveOwnerUserId(dependencies); + const connection = await dependencies.getCloudConnection(ownerUserId, "google"); + if (!connection?.refresh_token) throw new Error("Quilo 관리자 계정에 Google Drive 연결이 없습니다."); + const refreshToken = dependencies.decryptToken(connection.refresh_token); + const accessToken = await dependencies.getAccessToken(refreshToken); + if (!this.folderId) { + if (!this.folderPromise) { + this.folderPromise = dependencies.ensureDriveFolder(accessToken, { name: this.folderName }) + .then((folder) => { + this.folderId = safeIdentifier(folder?.id, "Google Drive 폴더"); + this.folderWebViewLink = String(folder?.webViewLink || ""); + return this.folderId; + }) + .catch((error) => { + this.folderPromise = null; + throw error; + }); + } + await this.folderPromise; + } else { + safeIdentifier(this.folderId, "Google Drive 폴더"); + if (!this.folderWebViewLink) { + const folder = await dependencies.getDriveFile(accessToken, this.folderId); + this.folderWebViewLink = String(folder?.webViewLink || ""); + } + } + return { accessToken, dependencies }; + } + + toClassbotFile(file) { + const clean = normalizedFile(file); + if (!clean) return null; + return { + id: this.encodeId(clean.sourceId), + member_id: null, + alias: clean.alias, + filename: clean.filename, + description: clean.description, + mime_type: clean.mimeType, + size_bytes: clean.sizeBytes, + provider: "google_drive", + status: "active", + created_at: clean.createdAt, + updated_at: clean.modifiedAt, + }; + } + + async listFiles() { + if (!this.configured) return []; + try { + const { accessToken, dependencies } = await this.context(); + const files = await dependencies.listDriveFiles(accessToken, { + folderId: this.folderId, + pageSize: 100, + }); + this.lastError = null; + return files.map((file) => this.toClassbotFile(file)).filter(Boolean); + } catch (error) { + this.lastError = error; + throw error; + } + } + + async getFile(managedId) { + if (!this.configured) return null; + const { accessToken, dependencies } = await this.context(); + const sourceId = this.decodeId(managedId); + const file = await dependencies.getDriveFile(accessToken, sourceId); + if (!Array.isArray(file?.parents) || !file.parents.includes(this.folderId)) { + throw new Error("설정된 Quilo schedule 자료실 밖의 파일에는 접근할 수 없습니다."); + } + const mapped = this.toClassbotFile(file); + if (!mapped) throw new Error("PDF 또는 지원되는 이미지 파일만 열 수 있습니다."); + return mapped; + } + + async downloadFile(managedId) { + const { accessToken, dependencies } = await this.context(); + const sourceId = this.decodeId(managedId); + const file = await dependencies.getDriveFile(accessToken, sourceId); + if (!Array.isArray(file?.parents) || !file.parents.includes(this.folderId)) { + throw new Error("설정된 Quilo schedule 자료실 밖의 파일에는 접근할 수 없습니다."); + } + if (!this.toClassbotFile(file)) throw new Error("PDF 또는 지원되는 이미지 파일만 열 수 있습니다."); + const downloaded = await dependencies.downloadDriveFile(accessToken, sourceId); + if (!Buffer.isBuffer(downloaded?.buffer) || downloaded.buffer.length < 1 || downloaded.buffer.length > MAX_FILE_BYTES) { + throw new Error("Google Drive 자료는 20MB 이하만 열 수 있습니다."); + } + return Buffer.from(downloaded.buffer); + } + + async uploadFile(input, body) { + if (!this.configured) throw new Error("Google Drive 자료실 운영 계정이 설정되지 않았습니다."); + if (!Buffer.isBuffer(body) || body.length < 1 || body.length > MAX_FILE_BYTES) { + throw new Error("Google Drive 자료는 20MB 이하여야 합니다."); + } + const mimeType = String(input?.mime_type || "").toLowerCase(); + if (!ALLOWED_MIME_TYPES.has(mimeType)) throw new Error("PDF 또는 지원되는 이미지 파일만 올릴 수 있습니다."); + const { accessToken, dependencies } = await this.context(); + const uploaded = await dependencies.uploadDriveFile(accessToken, { + name: String(input.filename || "Quilo 자료").slice(0, 180), + mimeType, + buffer: body, + folderId: this.folderId, + appProperties: { + quiloOrigin: "classbot", + classbotAlias: String(input.alias || "").slice(0, 60), + classbotDescription: String(input.description || "").slice(0, 1000), + }, + }); + const file = this.toClassbotFile(uploaded); + if (!file) throw new Error("Google Drive 업로드 결과를 확인할 수 없습니다."); + return file; + } + + async status() { + if (!this.ownerUserId && !this.ownerName) return { configured: false, connected: false, reason: "owner_user_missing", connect_url: null }; + if (!this.secret) return { configured: false, connected: false, reason: "signing_secret_missing", connect_url: null }; + const links = { connect_url: "/api/cloud/google/connect" }; + try { + await this.context(); + return { + configured: true, + connected: true, + folder_name: this.folderName, + folder_url: this.folderWebViewLink || null, + ...links, + }; + } catch { + return { configured: true, connected: false, reason: "google_drive_unavailable", ...links }; + } + } +} + +export class CompositeFileStore { + constructor(store, googleDrive, { logger = console } = {}) { + this.store = store; + this.googleDrive = googleDrive; + this.logger = logger; + } + + async listFiles(options = {}) { + const primary = await this.store.listFiles(options); + if (!this.googleDrive?.configured || (options.status && options.status !== "active")) return primary; + try { + const drive = await this.googleDrive.listFiles(); + return [...primary, ...drive] + .filter((file) => options.all || file.member_id == null || (options.targetMemberId && file.member_id === options.targetMemberId)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + } catch (error) { + this.logger.warn?.(`[classbot-drive] ${String(error?.message || error).slice(0, 200)}`); + return primary; + } + } + + getFile(fileId) { + return this.googleDrive?.isManagedId(fileId) ? this.googleDrive.getFile(fileId) : this.store.getFile(fileId); + } + + downloadFile(fileId) { + return this.googleDrive?.isManagedId(fileId) ? this.googleDrive.downloadFile(fileId) : this.store.downloadFile(fileId); + } + + createFile(...args) { + const [input, body, actor] = args; + if (!input?.member_id && this.googleDrive?.configured) { + return this.googleDrive.uploadFile(input, body).catch((error) => { + this.logger.warn?.(`[classbot-drive] upload fallback: ${String(error?.message || error).slice(0, 200)}`); + return this.store.createFile(input, body, actor); + }); + } + return this.store.createFile(input, body, actor); + } + + updateFile(fileId, ...args) { + if (this.googleDrive?.isManagedId(fileId)) { + throw Object.assign(new Error("Google Drive 자료의 이름과 설명은 Drive에서 관리해 주세요."), { status: 409 }); + } + return this.store.updateFile(fileId, ...args); + } + + deleteFile(fileId, ...args) { + if (this.googleDrive?.isManagedId(fileId)) { + throw Object.assign(new Error("Google Drive 자료 삭제는 Drive에서 직접 진행해 주세요."), { status: 409 }); + } + return this.store.deleteFile(fileId, ...args); + } +} + +export function createGoogleDriveFileProvider(config, dependencies) { + return new GoogleDriveFileProvider({ + folderId: config?.googleDrive?.folderId, + folderName: config?.googleDrive?.folderName, + ownerUserId: config?.googleDrive?.ownerUserId, + ownerName: config?.googleDrive?.ownerName, + secret: config?.sessionSecret, + dependencies, + }); +} diff --git a/apps/classbot/server/services/google-drive-files.test.js b/apps/classbot/server/services/google-drive-files.test.js new file mode 100644 index 0000000..87ec791 --- /dev/null +++ b/apps/classbot/server/services/google-drive-files.test.js @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CompositeFileStore, + GoogleDriveFileProvider, +} from "./google-drive-files.js"; + +function fixture({ files } = {}) { + const sourceFiles = files || [{ + id: "drive-file-1", + name: "김종수T 학습지.pdf", + mimeType: "application/pdf", + size: "2048", + parents: ["folder-1"], + capabilities: { canDownload: true }, + appProperties: { classbotAlias: "김종수T 학습지" }, + modifiedTime: "2026-07-16T00:00:00.000Z", + }]; + const byId = new Map(sourceFiles.map((file) => [file.id, file])); + const calls = []; + const dependencies = { + getCloudConnection: async (userId, provider) => { + calls.push(["connection", userId, provider]); + return { refresh_token: "encrypted-refresh" }; + }, + decryptToken: (value) => `decrypted:${value}`, + getAccessToken: async (value) => { + calls.push(["access", value]); + return "access-token"; + }, + ensureDriveFolder: async (_accessToken, options) => { + calls.push(["folder", options.name]); + return { id: "folder-1", name: options.name }; + }, + listDriveFiles: async (_accessToken, options) => { + calls.push(["list", options.folderId]); + return sourceFiles; + }, + getDriveFile: async (_accessToken, id) => byId.get(id), + downloadDriveFile: async (_accessToken, id) => ({ + buffer: Buffer.from(`drive:${id}`), + filename: byId.get(id)?.name, + mimeType: byId.get(id)?.mimeType, + }), + uploadDriveFile: async (_accessToken, options) => ({ + id: "uploaded-file", + name: options.name, + mimeType: options.mimeType, + size: String(options.buffer.length), + parents: [options.folderId], + capabilities: { canDownload: true }, + appProperties: options.appProperties, + createdTime: "2026-07-16T01:00:00.000Z", + modifiedTime: "2026-07-16T01:00:00.000Z", + }), + }; + const provider = new GoogleDriveFileProvider({ + folderName: "Quilo schedule 자료실", + ownerUserId: "owner-user-1", + secret: "drive-provider-test-secret", + dependencies, + }); + return { provider, calls, dependencies, sourceFiles }; +} + +test("Quilo OAuth로 전용 Drive 폴더를 만들고 PDF·이미지만 class-wide metadata로 노출한다", async () => { + const { provider, calls } = fixture({ files: [ + { + id: "drive-file-1", + name: "김종수T 학습지.pdf", + mimeType: "application/pdf", + size: "2048", + parents: ["folder-1"], + capabilities: { canDownload: true }, + appProperties: { classbotAlias: "김종수T 학습지", classbotDescription: "수업 자료" }, + modifiedTime: "2026-07-16T00:00:00.000Z", + }, + { + id: "native-doc", + name: "비공개 문서", + mimeType: "application/vnd.google-apps.document", + parents: ["folder-1"], + }, + { + id: "oversize-image", + name: "큰 이미지.png", + mimeType: "image/png", + size: String(21 * 1024 * 1024), + parents: ["folder-1"], + }, + ] }); + + const items = await provider.listFiles(); + assert.equal(items.length, 1); + assert.equal(items[0].alias, "김종수T 학습지"); + assert.equal(items[0].member_id, null); + assert.equal(items[0].provider, "google_drive"); + assert.match(items[0].id, /^gdrive_/); + assert.equal(calls.filter(([kind]) => kind === "folder").length, 1); + + await provider.listFiles(); + assert.equal(calls.filter(([kind]) => kind === "folder").length, 1, "한 프로세스에서는 폴더를 중복 생성하지 않는다"); +}); + +test("owner UUID가 없어도 기존 Quilo 계정의 정확한 이름으로 Drive 연결을 찾는다", async () => { + const { dependencies, calls } = fixture(); + dependencies.findUserByName = async (name) => { + calls.push(["owner", name]); + return { id: "resolved-owner-user", name: "구민준" }; + }; + const provider = new GoogleDriveFileProvider({ + ownerName: "구민준", + secret: "drive-provider-test-secret", + dependencies, + }); + + const items = await provider.listFiles(); + assert.equal(items.length, 1); + assert.deepEqual(calls.find(([kind]) => kind === "owner"), ["owner", "구민준"]); + assert.deepEqual(calls.find(([kind]) => kind === "connection"), ["connection", "resolved-owner-user", "google"]); + assert.equal(provider.ownerUserId, "resolved-owner-user"); +}); + +test("Drive 다운로드는 서명된 provider ID와 설정 폴더 parent를 모두 검증한다", async () => { + const { provider, sourceFiles } = fixture(); + const [item] = await provider.listFiles(); + assert.equal((await provider.downloadFile(item.id)).toString(), "drive:drive-file-1"); + + await assert.rejects(() => provider.getFile(`${item.id.slice(0, -1)}x`), /올바르지 않은 Google Drive 자료/); + sourceFiles[0].parents = ["other-folder"]; + await assert.rejects(() => provider.downloadFile(item.id), /자료실 밖/); +}); + +test("반 전체 업로드는 Drive 폴더에 저장하고 개인 자료는 기존 저장소에 남긴다", async () => { + const { provider } = fixture(); + const localCalls = []; + const local = { + listFiles: async () => [], + createFile: async (input) => { localCalls.push(input); return { id: "local-private", ...input }; }, + }; + const store = new CompositeFileStore(local, provider, { logger: { warn() {} } }); + const body = Buffer.from("%PDF-1.4\n%%EOF"); + const classFile = await store.createFile({ + alias: "반 학습지", + filename: "worksheet.pdf", + mime_type: "application/pdf", + }, body, "admin"); + assert.equal(classFile.provider, "google_drive"); + assert.equal(classFile.alias, "반 학습지"); + assert.equal(localCalls.length, 0); + + const privateFile = await store.createFile({ + member_id: "member-1", + alias: "개인 피드백", + filename: "feedback.pdf", + mime_type: "application/pdf", + }, body, "admin"); + assert.equal(privateFile.id, "local-private"); + assert.equal(localCalls.length, 1); +}); + +test("Composite file store는 Drive 장애 시 기존 Supabase 파일 목록으로 안전하게 fallback한다", async () => { + const warnings = []; + const local = { + listFiles: async () => [{ id: "local-1", member_id: null, status: "active", created_at: "2026-07-15T00:00:00Z" }], + getFile: async () => null, + downloadFile: async () => Buffer.alloc(0), + createFile: async () => null, + updateFile: async () => null, + deleteFile: async () => null, + }; + const drive = { + configured: true, + isManagedId: (id) => String(id).startsWith("gdrive_"), + listFiles: async () => { throw new Error("refresh token failed"); }, + }; + const store = new CompositeFileStore(local, drive, { logger: { warn: (value) => warnings.push(value) } }); + assert.deepEqual(await store.listFiles({ all: true }), await local.listFiles()); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /refresh token failed/); + assert.throws(() => store.deleteFile("gdrive_file.sig"), /Drive에서 직접/); +}); diff --git a/apps/classbot/server/services/kakao.js b/apps/classbot/server/services/kakao.js index f6a88f2..3fb7b87 100644 --- a/apps/classbot/server/services/kakao.js +++ b/apps/classbot/server/services/kakao.js @@ -95,7 +95,7 @@ export function registeredQuickReplies() { "다음 일정", "수행평가 과제 통합 요약", "시간표 전체", - "자료 목록", + "파일 리스트", ].map((messageText) => ({ label: messageText, action: "message", messageText })); } diff --git a/apps/classbot/server/services/kakao.test.js b/apps/classbot/server/services/kakao.test.js index 2eea099..1efc3c8 100644 --- a/apps/classbot/server/services/kakao.test.js +++ b/apps/classbot/server/services/kakao.test.js @@ -68,6 +68,6 @@ test("이름등록이 끝난 요청자용 Quick Reply는 이름 suffix 없이 5 "다음 일정", "수행평가 과제 통합 요약", "시간표 전체", - "자료 목록", + "파일 리스트", ]); }); diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 1edf422..6926245 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -265,6 +265,7 @@ export class MemoryStore { this.auditLogs = []; this.files = []; this.fileBodies = new Map(); + this.kakaoStates = new Map(); } async healthCheck() { @@ -399,6 +400,36 @@ export class MemoryStore { return clone(member); } + async claimMemberByName({ displayName, userKey, userKeyType = "botUserKey" }) { + const name = String(displayName || "").trim(); + const normalizedKey = String(userKey || "").trim(); + if (!name) throw new Error("명단의 이름을 정확히 입력해 주세요."); + if (name.length > 40) throw new Error("명단의 이름을 40자 이내로 정확히 입력해 주세요."); + if (!normalizedKey) throw new Error("카카오 사용자 식별값이 필요합니다."); + if (!["botUserKey", "plusfriendUserKey", "appUserId"].includes(userKeyType)) throw new Error("올바른 카카오 사용자 식별값 유형이 아닙니다."); + + const matches = this.members.filter((item) => item.status !== "left" && String(item.display_name || "").trim() === name); + if (matches.length !== 1) { + throw new Error(matches.length > 1 + ? `명단에 '${name}' 동명이인이 있어 이름만으로 등록할 수 없습니다.` + : `명단에서 '${name}'을(를) 찾을 수 없습니다. 이름을 정확히 입력해 주세요.`); + } + const member = matches[0]; + if (member.status === "disabled") throw new Error("비활성 구성원은 이름을 등록할 수 없습니다."); + const boundToKey = this.members.find((item) => item.kakao_user_key === normalizedKey); + if (boundToKey && boundToKey.id !== member.id) throw new Error("이미 다른 구성원으로 등록된 카카오 계정입니다."); + if (member.kakao_user_key && member.kakao_user_key !== normalizedKey) throw new Error("이미 다른 카카오 계정에 등록된 이름입니다. 관리자에게 문의해 주세요."); + if (member.kakao_user_key === normalizedKey && member.status === "active") return clone(member); + + member.kakao_user_key = normalizedKey; + member.kakao_user_key_type = userKeyType; + member.status = "active"; + member.joined_at ||= nowIso(); + member.updated_at = nowIso(); + await this.appendAudit({ actor: member.id, action: "member.name_claim", entityType: "member", entityId: member.id, after: { status: "active" } }); + return clone(member); + } + async claimPortalInvite({ memberId, code }) { const member = this.members.find((item) => item.id === memberId); const codeHash = hashInviteCode(code); @@ -425,6 +456,31 @@ export class MemoryStore { return member ? clone(member) : null; } + async setPendingFileSelection({ memberId, fileIds, expiresAt }) { + const member = this.members.find((item) => item.id === memberId && item.status === "active"); + const ids = [...new Set((Array.isArray(fileIds) ? fileIds : []).map(String).filter(Boolean))].slice(0, 3); + const expires = new Date(expiresAt); + if (!member) throw new Error("구성원을 찾을 수 없습니다."); + if (!ids.length || Number.isNaN(expires.getTime())) throw new Error("파일 후보 상태가 올바르지 않습니다."); + const state = { class_id: this.classroom.id, member_id: memberId, file_ids: ids, expires_at: expires.toISOString() }; + this.kakaoStates.set(memberId, state); + return clone(state); + } + + async getPendingFileSelection(memberId) { + const state = this.kakaoStates.get(memberId); + if (!state) return null; + if (new Date(state.expires_at).getTime() <= Date.now()) { + this.kakaoStates.delete(memberId); + return null; + } + return clone(state); + } + + async clearPendingFileSelection(memberId) { + this.kakaoStates.delete(memberId); + } + async listTimetable({ weekday } = {}) { return clone(this.timetable) .filter((row) => weekday == null || row.weekday === Number(weekday)) diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index e0b53b6..6b4c995 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,8 +34,8 @@ test("스키마 버전 health RPC와 전체 RLS가 운영 준비 상태를 검 assert.match(schema, /create table if not exists public\.classbot_schema_meta/); assert.match(schema, /create or replace function public\.classbot_health_check\(\)/); assert.match(schema, /grant execute on function public\.classbot_health_check\(\) to service_role/); - assert.match(schema, /values \(1, 4, now\(\)\)/); - for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "notifications", "audit_logs"]) { + assert.match(schema, /values \(1, 5, now\(\)\)/); + for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "kakao_states", "notifications", "audit_logs"]) { assert.match(schema, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } assert.match(storeSource, /\.rpc\("classbot_health_check"\)/); @@ -51,7 +51,25 @@ test("개인별 시간표는 학급-구성원 복합 경계와 원자적 전체 assert.match(replaceFunction, /class_id = p_class_id[\s\S]*id = p_member_id[\s\S]*for update/); assert.match(replaceFunction, /delete from public\.classbot_member_timetable/); assert.match(storeSource, /\.rpc\("classbot_replace_member_timetable"/); - assert.match(storeSource, /Number\(version\) !== 4/); + assert.match(storeSource, /Number\(version\) !== 5/); +}); + +test("이름 등록 RPC는 학급 lock과 정확 일치로 key 탈취·재바인딩을 막는다", () => { + const claimFunction = schema.match(/create or replace function public\.classbot_claim_member_by_name[\s\S]*?\$\$;/)?.[0] || ""; + assert.match(claimFunction, /from public\.classbot_classes[\s\S]*for update/); + assert.match(claimFunction, /display_name = trim\(p_display_name\)/); + assert.match(claimFunction, /matching_count > 1/); + assert.match(claimFunction, /kakao_user_key = trim\(p_user_key\)[\s\S]*id <> selected_member\.id/); + assert.match(claimFunction, /selected_member\.kakao_user_key is not null[\s\S]*selected_member\.kakao_user_key <> trim\(p_user_key\)/); + assert.match(storeSource, /\.rpc\("classbot_claim_member_by_name"/); +}); + +test("카카오 파일 후보 상태는 구성원 경계·최대 3개·만료 시각과 RLS를 강제한다", () => { + assert.match(schema, /create table if not exists public\.classbot_kakao_states/); + assert.match(schema, /foreign key \(class_id, member_id\)[\s\S]*references public\.classbot_members\(class_id, id\)/); + assert.match(schema, /cardinality\(pending_file_ids\) between 1 and 3/); + assert.match(schema, /pending_expires_at timestamptz not null/); + assert.match(storeSource, /from\("classbot_kakao_states"\)[\s\S]*pending_expires_at/); }); test("카카오와 학생 포털은 같은 초대 코드의 일회성 사용 상태를 채널별로 분리한다", () => { diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index 285fd39..bed3acc 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -135,7 +135,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 4) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 5) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -276,6 +276,23 @@ export class SupabaseStore { return member; } + async claimMemberByName({ displayName, userKey, userKeyType = "botUserKey" }) { + const classroom = await this.ensureClassroom(); + const claimed = unwrap( + await this.client.rpc("classbot_claim_member_by_name", { + p_class_id: classroom.id, + p_display_name: String(displayName || "").trim(), + p_user_key: String(userKey || "").trim(), + p_user_key_type: userKeyType, + }), + "이름 등록 처리 실패", + ); + const member = claimed?.[0]; + if (!member) throw new Error("명단에서 이름을 찾을 수 없습니다. 이름을 정확히 입력해 주세요."); + await this.appendAudit({ actor: member.id, action: "member.name_claim", entityType: "member", entityId: member.id, after: { status: "active" } }); + return member; + } + async claimPortalInvite({ memberId, code }) { const classroom = await this.ensureClassroom(); const usedAt = new Date().toISOString(); @@ -319,6 +336,64 @@ export class SupabaseStore { ); } + async setPendingFileSelection({ memberId, fileIds, expiresAt }) { + const classroom = await this.ensureClassroom(); + const ids = [...new Set((Array.isArray(fileIds) ? fileIds : []).map(String).filter(Boolean))].slice(0, 3); + const expires = new Date(expiresAt); + if (!String(memberId || "").trim() || !ids.length || Number.isNaN(expires.getTime())) throw new Error("파일 후보 상태가 올바르지 않습니다."); + const state = unwrap( + await this.client + .from("classbot_kakao_states") + .upsert({ + class_id: classroom.id, + member_id: memberId, + pending_file_ids: ids, + pending_expires_at: expires.toISOString(), + }, { onConflict: "member_id" }) + .select("class_id,member_id,pending_file_ids,pending_expires_at") + .single(), + "파일 후보 저장 실패", + ); + return { + class_id: state.class_id, + member_id: state.member_id, + file_ids: state.pending_file_ids, + expires_at: state.pending_expires_at, + }; + } + + async getPendingFileSelection(memberId) { + const classroom = await this.ensureClassroom(); + const state = unwrap( + await this.client + .from("classbot_kakao_states") + .select("class_id,member_id,pending_file_ids,pending_expires_at") + .eq("class_id", classroom.id) + .eq("member_id", memberId) + .gt("pending_expires_at", new Date().toISOString()) + .maybeSingle(), + "파일 후보 조회 실패", + ); + return state ? { + class_id: state.class_id, + member_id: state.member_id, + file_ids: state.pending_file_ids, + expires_at: state.pending_expires_at, + } : null; + } + + async clearPendingFileSelection(memberId) { + const classroom = await this.ensureClassroom(); + unwrap( + await this.client + .from("classbot_kakao_states") + .delete() + .eq("class_id", classroom.id) + .eq("member_id", memberId), + "파일 후보 삭제 실패", + ); + } + async listTimetable({ weekday } = {}) { const classroom = await this.ensureClassroom(); let query = this.client.from("classbot_timetable").select("*").eq("class_id", classroom.id); diff --git a/apps/classbot/server/store/supabase-store.test.js b/apps/classbot/server/store/supabase-store.test.js index d4456b2..386059d 100644 --- a/apps/classbot/server/store/supabase-store.test.js +++ b/apps/classbot/server/store/supabase-store.test.js @@ -70,3 +70,40 @@ test("개인 시간표 교체는 학급과 구성원을 함께 scope한 원자 }]); assert.deepEqual(audits[0].after, { member_id: "member-1", row_count: 1 }); }); + +test("Kakao key 조회와 파일 후보 저장은 Supabase 결과를 store 계약 형태로 반환한다", async () => { + const store = Object.create(SupabaseStore.prototype); + store.classroom = { id: "class-private" }; + const member = { id: "member-1", display_name: "홍길동", status: "active" }; + const savedState = { + class_id: "class-private", + member_id: "member-1", + pending_file_ids: ["gdrive_signed-file-id", "supabase-file-id"], + pending_expires_at: "2026-07-16T12:00:00.000Z", + }; + store.client = { + from(table) { + const query = { + select() { return query; }, + eq() { return query; }, + upsert() { return query; }, + async maybeSingle() { return { data: member, error: null }; }, + async single() { return { data: savedState, error: null }; }, + }; + assert.ok(["classbot_members", "classbot_kakao_states"].includes(table)); + return query; + }, + }; + + assert.deepEqual(await store.findMemberByUserKey("kakao-key"), member); + assert.deepEqual(await store.setPendingFileSelection({ + memberId: "member-1", + fileIds: savedState.pending_file_ids, + expiresAt: savedState.pending_expires_at, + }), { + class_id: "class-private", + member_id: "member-1", + file_ids: savedState.pending_file_ids, + expires_at: savedState.pending_expires_at, + }); +}); diff --git a/apps/classbot/src/App.jsx b/apps/classbot/src/App.jsx index 9acdb3f..672a7e0 100644 --- a/apps/classbot/src/App.jsx +++ b/apps/classbot/src/App.jsx @@ -5,11 +5,9 @@ import { BottomNavigation, Sidebar, Topbar } from "./components/AppShell.jsx"; import { EventDrawer, NoticeDrawer } from "./components/Editors.jsx"; import TodayPage from "./pages/TodayPage.jsx"; import EventsPage from "./pages/EventsPage.jsx"; -import TimetablePage from "./pages/TimetablePage.jsx"; import NoticesPage from "./pages/NoticesPage.jsx"; import FilesPage from "./pages/FilesPage.jsx"; import MembersPage from "./pages/MembersPage.jsx"; -import NotificationsPage from "./pages/NotificationsPage.jsx"; import SettingsPage from "./pages/SettingsPage.jsx"; import PortalLogin from "./portal/PortalLogin.jsx"; import StudentPortal from "./portal/StudentPortal.jsx"; @@ -40,7 +38,7 @@ export default function App() { const [overview, setOverview] = useState(null); const [active, setActive] = useState("today"); const [drawer, setDrawer] = useState({ type: null, item: null, open: false }); - const [fileLibrary, setFileLibrary] = useState({ items: [], loading: false, loaded: false, error: "" }); + const [fileLibrary, setFileLibrary] = useState({ items: [], loading: false, loaded: false, error: "", drive: null }); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [toast, setToast] = useState(""); @@ -74,8 +72,18 @@ export default function App() { const loadFiles = async () => { setFileLibrary((current) => ({ ...current, loading: true, error: "" })); try { - const result = await api.files(); - setFileLibrary({ items: result.items || result.files || [], loading: false, loaded: true, error: "" }); + const [result, drive] = await Promise.all([ + api.files(), + api.driveStatus().catch(() => ({ configured: false, connected: false, reason: "status_unavailable" })), + ]); + const items = result.items || result.files || []; + setFileLibrary({ + items, + loading: false, + loaded: true, + error: "", + drive: { ...drive, item_count: items.filter((item) => item.provider === "google_drive").length }, + }); } catch (err) { setFileLibrary((current) => ({ ...current, loading: false, loaded: false, error: err.message || "자료 목록 요청에 실패했습니다." })); } @@ -90,7 +98,7 @@ export default function App() { const logoutPortal = async () => { if (busy) return; setBusy(true); setError(""); - try { await api.portalLogout(); setPortalSession({ authenticated: false }); } + try { await api.quiloLogout(); window.location.assign("/"); } catch (err) { setError(err.message || "로그아웃하지 못했습니다."); } finally { setBusy(false); } }; @@ -112,17 +120,22 @@ export default function App() { const uploadFile = async (input) => { if (busy) return false; setBusy(true); try { const result = await api.uploadFile(input); setFileLibrary((current) => ({ ...current, items: [result.item, ...current.items], loaded: true, error: "" })); flash("자료를 업로드했습니다."); return true; } catch (err) { flash(err.message); return false; } finally { setBusy(false); } }; const deleteFile = async (item) => { if (busy || !window.confirm(`'${item.alias || item.original_name || "자료"}'을 삭제할까요?`)) return; setBusy(true); try { await api.deleteFile(item.id); setFileLibrary((current) => ({ ...current, items: current.items.filter((file) => file.id !== item.id) })); flash("자료를 삭제했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }; const copyFileLink = async (item) => { try { await copyText(api.fileShareUrl(item)); flash("자료 링크를 복사했습니다."); } catch { flash("링크를 복사하지 못했습니다."); } }; + const syncDrive = async () => { + if (busy) return; + setBusy(true); + try { await api.syncDrive(); await loadFiles(); flash("Google Drive 자료를 동기화했습니다."); } + catch (err) { flash(err.message || "Google Drive를 동기화하지 못했습니다."); } + finally { setBusy(false); } + }; const navigate = (id) => { setActive(id); if (id === "files" && !fileLibrary.loaded && !fileLibrary.loading) loadFiles(); if (drawer.open) closeDrawer(); }; const screen = useMemo(() => { if (!overview) return null; if (active === "today") return setDrawer({ type: "event", item, open: true })} onNavigate={navigate} />; if (active === "events") return setDrawer({ type: "event", item, open: true })} />; - if (active === "timetable") return { if (busy) return; setBusy(true); try { const result = await api.saveTimetable(weekday, items); setOverview((current) => ({ ...current, timetable: [...current.timetable.filter((item) => item.weekday !== weekday), ...result.items] })); flash(`${["", "월", "화", "수", "목", "금"][weekday]}요일 시간표를 저장했습니다.`); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; - if (active === "notices") return setDrawer({ type: "notice", item, open: true })} onSend={async (id) => { if (busy) return; setBusy(true); try { const result = await api.sendNotice(id); patchCollection("notices", result.item); flash("게시 요청을 접수했습니다. 전송 결과는 알림 기록에서 확인하세요."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; - if (active === "files") return ; + if (active === "notices") return setDrawer({ type: "notice", item, open: true })} onSend={async (id) => { if (busy) return; setBusy(true); try { const result = await api.sendNotice(id); patchCollection("notices", result.item); flash("게시 요청을 접수했습니다. 전송 상태는 오늘 화면에서 확인할 수 있습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; + if (active === "files") return ; if (active === "members") return { if (busy) return false; setBusy(true); try { const result = await api.createMember(input); addCollection("members", result.item); flash("구성원을 추가했습니다."); return true; } catch (err) { flash(err.message); return false; } finally { setBusy(false); } }} onUpdate={async (id, patch) => { if (busy) return; setBusy(true); try { const result = await api.updateMember(id, patch); patchCollection("members", result.item); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onInvite={async (id) => { if (busy) return; setBusy(true); try { const result = await api.inviteMember(id); await navigator.clipboard?.writeText(result.invite_url || result.code || ""); flash("초대 링크를 복사했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; - if (active === "notifications") return { if (busy) return; setBusy(true); try { const result = await api.notifications(); setOverview((current) => ({ ...current, notifications: result.items })); flash("알림 상태를 새로고침했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onTest={async () => { if (busy) return; setBusy(true); try { const result = await api.testNotification(); if (result.item) addCollection("notifications", result.item); flash("테스트 알림 요청을 접수했습니다. 아직 전송 완료 상태는 아닙니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} />; return { if (busy) return; setBusy(true); try { const result = await api.updateSettings(patch); setOverview((current) => ({ ...current, classroom: result.item })); flash("설정을 저장했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }} onLogout={async () => { await api.logout(); setSession({ authenticated: false }); setPortalSession({ authenticated: false }); setOverview(null); }} />; }, [active, overview, busy, drawer.item, fileLibrary]); @@ -131,12 +144,12 @@ export default function App() { if (!session) return ; if (!session.authenticated) { if (portalSession?.authenticated) return ; - return ; + return ; } if (!overview) return
{error || "데이터를 불러오지 못했습니다."}
; return (
- +
{screen}
diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js index f93ad03..2112a2b 100644 --- a/apps/classbot/src/api/client.js +++ b/apps/classbot/src/api/client.js @@ -87,6 +87,7 @@ function createItem(collection, prefix, input) { export const api = { get mode() { return transport; }, + get embedded() { return EMBEDDED_IN_QUILO; }, get demoFallbackEnabled() { return DEMO_FALLBACK_ENABLED; }, async session() { return remoteOrLocal(() => request("/api/admin/session"), () => ({ authenticated: true, actor: "demo-admin", demo: true })); @@ -141,6 +142,13 @@ export const api = { deleteNotice(id) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "DELETE" }), () => { localState.notices = localState.notices.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); }, sendNotice(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/notices/${id}/send`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notice-send") } }), () => updateItem("notices", id, { status: "published", published_at: new Date().toISOString() })); }, files() { return remoteOrLocal(() => request("/api/admin/files"), () => ({ items: structuredClone(localState.files) })); }, + driveStatus() { + return remoteOrLocal( + () => request("/api/admin/drive/status"), + () => ({ configured: false, connected: false, reason: "demo_mode" }), + ); + }, + syncDrive() { return request("/api/admin/drive/sync", { method: "POST" }); }, uploadFile(input, options) { const body = new FormData(); body.append("file", input.file); @@ -180,6 +188,11 @@ export const api = { portalSession() { return request("/api/portal/session"); }, portalLogin({ display_name, invite_code }) { return request("/api/portal/login", { method: "POST", body: { display_name, invite_code } }); }, portalLogout() { return request("/api/portal/logout", { method: "POST" }); }, + quiloLogout() { + return EMBEDDED_IN_QUILO + ? request("/api/logout", { method: "POST", root: true }) + : request("/api/portal/logout", { method: "POST" }); + }, portalOverview(from, to) { const query = new URLSearchParams({ from, to }); return request(`/api/portal/overview?${query.toString()}`); diff --git a/apps/classbot/src/components/AppShell.jsx b/apps/classbot/src/components/AppShell.jsx index e73cbbf..1c697ab 100644 --- a/apps/classbot/src/components/AppShell.jsx +++ b/apps/classbot/src/components/AppShell.jsx @@ -1,16 +1,14 @@ import { - Bell, CalendarDays, ChevronDown, ClipboardList, Clock3, + CalendarDays, ChevronDown, Clock3, FolderOpen, LayoutDashboard, Megaphone, Plus, Settings, Users, X, } from "lucide-react"; const navItems = [ ["today", "오늘", LayoutDashboard], ["events", "일정", CalendarDays], - ["timetable", "시간표", ClipboardList], - ["notices", "반 공지", Megaphone], + ["notices", "공지", Megaphone], ["files", "자료실", FolderOpen], ["members", "구성원", Users], - ["notifications", "알림 기록", Bell], ["settings", "설정", Settings], ]; @@ -18,11 +16,19 @@ export function Brand() { return
Quilo schedule
; } -export function Sidebar({ active, onNavigate, classroom, memberCount }) { +export function Sidebar({ + active, + onNavigate, + classroom, + memberCount, + profileName = "구민준", + profileRole = "관리자", + menuLabel = "Quilo schedule 메뉴", +}) { return (
-
1학생 1반장
+
{profileName.slice(0, 1)}{profileName}{profileRole}
); @@ -54,7 +60,7 @@ export function BottomNavigation({ active, onNavigate }) { return ( ); diff --git a/apps/classbot/src/pages/FilesPage.jsx b/apps/classbot/src/pages/FilesPage.jsx index 5c286c9..77b7397 100644 --- a/apps/classbot/src/pages/FilesPage.jsx +++ b/apps/classbot/src/pages/FilesPage.jsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from "react"; import { - Copy, ExternalLink, FileImage, FileText, FolderOpen, LoaderCircle, - LockKeyhole, Trash2, Upload, Users, X, + Cloud, Copy, ExternalLink, FileImage, FileText, FolderOpen, LoaderCircle, + LockKeyhole, RefreshCw, Trash2, Upload, Users, X, } from "lucide-react"; import { dateLabel } from "../lib/format.js"; @@ -28,7 +28,14 @@ function fileSize(item) { return `${(value / 1024 ** 2).toFixed(value < 10 * 1024 ** 2 ? 1 : 0)} MB`; } -export default function FilesPage({ files, members, busy, loading, error, onUpload, onDelete, onCopy, onRefresh, downloadUrl }) { +function driveStatusText(drive) { + if (drive?.connected) return `${drive.folder_name || "Quilo schedule 자료실"} · ${drive.item_count || 0}개 동기화`; + if (drive?.reason === "owner_user_missing") return "Google Drive 운영 계정 이름을 설정해 주세요."; + if (drive?.reason === "demo_mode") return "데모에서는 Google Drive를 연결하지 않습니다."; + return "Quilo 계정의 Google Drive 연결을 확인해 주세요."; +} + +export default function FilesPage({ files, members, busy, loading, error, drive, onDriveSync, onUpload, onDelete, onCopy, onRefresh, downloadUrl }) { const inputRef = useRef(null); const [file, setFile] = useState(null); const [alias, setAlias] = useState(""); @@ -65,7 +72,19 @@ export default function FilesPage({ files, members, busy, loading, error, onUplo return (
-

자료실

PDF와 이미지를 반 전체 또는 선택한 구성원에게 안전하게 공유합니다.

+
+

자료실

PDF와 이미지를 반 전체 또는 선택한 구성원에게 안전하게 공유합니다.

+ + {drive?.folder_url ? Drive 열기 : null} + + +
+ +
+ + {drive?.connected ? "Google Drive 연결됨" : "Google Drive 연결 필요"}{driveStatusText(drive)} + {!drive?.connected && drive?.connect_url ? Quilo 연결 설정 열기 : null} +

자료 업로드

PDF · 이미지
@@ -85,7 +104,7 @@ export default function FilesPage({ files, members, busy, loading, error, onUplo