diff --git a/apps/classbot/README.md b/apps/classbot/README.md index e9dd49c..b1c231b 100644 --- a/apps/classbot/README.md +++ b/apps/classbot/README.md @@ -1,14 +1,17 @@ -# Quilo +# Quilo schedule -2학년 4반의 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 알림을 관리하는 Quilo 내장 기능이다. 관리자 화면은 데스크톱과 모바일을 지원한다. 학생은 카카오 챗봇에서 이름을 질문 맨 뒤에 붙여 반 공통 일정과 본인의 개인 일정을 조회하며, 초대 코드로 가입한 뒤에는 알림 수신 여부도 바꿀 수 있다. +2학년 4반의 개인별 시간표, 수행평가·과제 일정, 반 공지와 카카오톡 조회를 관리하는 Quilo 내장 기능이다. 관리자와 학생 화면은 데스크톱·모바일을 지원한다. 학생은 최초 한 번 이름과 초대 코드로 본인 확인한 뒤 웹과 카카오에서 이름을 반복 입력하지 않아도 된다. ## 현재 구현 범위 -- 오늘 대시보드, 일정, 시간표, 반 공지, 구성원, 알림 기록, 설정 -- 최대 16명 구성원과 1회용 초대 코드 가입 -- 카카오 챗봇 명령: `오늘 일정 학생 1`, `내일 시간표 학생 1`, `이번 주 수행평가 학생 1`, `과제 학생 1`, `공지`, `알림 설정` -- 조회 답변의 퀵 리플은 조회한 구성원 이름을 맨 뒤에 붙여 다음 질문을 바로 선택할 수 있게 한다. +- 관리자용 오늘 대시보드, 일정, 시간표, 반 공지, 자료실, 구성원, 알림 기록, 설정 +- 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브 +- 최대 16명 구성원과 1회용 초대 코드 가입, 30일 HttpOnly 학생 포털 세션 +- 카카오 챗봇 명령: `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `자료 목록`, `PDF 별칭`, `이미지 별칭` +- 미등록 사용자는 질문 맨 뒤에 등록 이름을 붙일 수 있고, `이름등록 ABCD-EFGH`를 한 뒤에는 본인 조회에서 이름을 생략한다. - 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림 +- 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다. +- 반 전체 자료와 본인 개인 자료만 노출하는 비공개 파일 저장소와 15분 열기·다운로드 링크 - 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림 - Event API 작업 결과 조회와 사용자별 성공·실패 기록 - 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그 @@ -41,7 +44,7 @@ npm run release:check ## 운영 배포 -1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. +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) 순서로 적용한다. 2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다. 3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다. 4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다. @@ -56,8 +59,9 @@ 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` -- 일정 조회: `오늘 일정 학생 1`처럼 등록된 구성원 이름을 항상 맨 뒤에 입력 -- 학생 가입: 자동 알림이 필요할 때 관리자 화면에서 초대 코드를 만든 뒤 챗봇에 `가입 ABCD-EFGH` 입력 +- 최초 이름 등록: 관리자 화면에서 받은 일회용 코드를 챗봇에 `이름등록 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/002_files.sql b/apps/classbot/db/migrations/002_files.sql new file mode 100644 index 0000000..c7dfa46 --- /dev/null +++ b/apps/classbot/db/migrations/002_files.sql @@ -0,0 +1,42 @@ +begin; + +create table if not exists public.classbot_files ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid references public.classbot_members(id) on delete cascade, + alias text not null check (char_length(alias) between 1 and 60), + filename text not null check (char_length(filename) between 1 and 180), + description text not null default '' check (char_length(description) <= 1000), + mime_type text not null check (mime_type in ('application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif')), + size_bytes bigint not null check (size_bytes between 1 and 20971520), + bucket text not null, + object_path text not null unique, + status text not null default 'active' check (status in ('active', 'deleted')), + created_by text not null default 'admin', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists classbot_files_class_alias_idx + on public.classbot_files(class_id, lower(alias)) + where member_id is null and status = 'active'; + +create unique index if not exists classbot_files_member_alias_idx + on public.classbot_files(class_id, member_id, lower(alias)) + where member_id is not null and status = 'active'; + +create index if not exists classbot_files_list_idx + on public.classbot_files(class_id, status, created_at desc); + +drop trigger if exists set_updated_at on public.classbot_files; +create trigger set_updated_at +before update on public.classbot_files +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_files enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 2, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; + +commit; diff --git a/apps/classbot/db/migrations/003_member_timetable.sql b/apps/classbot/db/migrations/003_member_timetable.sql new file mode 100644 index 0000000..191c9b6 --- /dev/null +++ b/apps/classbot/db/migrations/003_member_timetable.sql @@ -0,0 +1,159 @@ +-- Classbot schema v3: per-member timetables. +-- Run with the Supabase service/database owner role. This migration contains +-- no roster or timetable data and is safe to re-run. + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'classbot_members_class_id_id_key' + and conrelid = 'public.classbot_members'::regclass + ) then + alter table public.classbot_members + add constraint classbot_members_class_id_id_key unique (class_id, id); + end if; +end; +$$; + +create table if not exists public.classbot_member_timetable ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null, + member_id uuid not null, + weekday integer not null check (weekday between 1 and 5), + period integer not null check (period between 1 and 12), + subject text not null check (char_length(trim(subject)) between 1 and 100), + activity text not null default '' check (char_length(activity) <= 300), + teacher text not null default '' check (char_length(teacher) <= 100), + room text not null default '' check (char_length(room) <= 100), + memo text not null default '' check (char_length(memo) <= 500), + effective_from date not null default current_date, + effective_to date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint classbot_member_timetable_member_fkey + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) + on delete cascade, + constraint classbot_member_timetable_effective_dates_check + check (effective_to is null or effective_to >= effective_from), + constraint classbot_member_timetable_slot_key + unique (class_id, member_id, weekday, period, effective_from) +); + +create index if not exists classbot_member_timetable_lookup_idx + on public.classbot_member_timetable(class_id, member_id, weekday, effective_from, effective_to, period); + +create or replace function public.classbot_replace_member_timetable( + p_class_id uuid, + p_member_id uuid, + p_rows jsonb +) +returns setof public.classbot_member_timetable +language plpgsql +security definer +set search_path = public +as $$ +begin + if jsonb_typeof(coalesce(p_rows, '[]'::jsonb)) <> 'array' then + raise exception '개인 시간표 데이터는 배열이어야 합니다.'; + end if; + + perform 1 + from public.classbot_members + where class_id = p_class_id + and id = p_member_id + and status <> 'left' + for update; + if not found then + raise exception '개인 시간표를 등록할 구성원을 찾을 수 없습니다.'; + end if; + + if exists ( + select 1 + from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data( + weekday integer, + period integer, + subject text, + activity text, + teacher text, + room text, + memo text, + effective_from date, + effective_to date + ) + where row_data.weekday is null + or row_data.weekday < 1 + or row_data.weekday > 5 + or row_data.period is null + or row_data.period < 1 + or row_data.period > 12 + or nullif(trim(row_data.subject), '') is null + or char_length(trim(row_data.subject)) > 100 + or char_length(coalesce(row_data.activity, '')) > 300 + or char_length(coalesce(row_data.teacher, '')) > 100 + or char_length(coalesce(row_data.room, '')) > 100 + or char_length(coalesce(row_data.memo, '')) > 500 + or ( + row_data.effective_to is not null + and row_data.effective_to < coalesce(row_data.effective_from, current_date) + ) + ) then + raise exception '개인 시간표 행 형식이 올바르지 않습니다.'; + end if; + + delete from public.classbot_member_timetable + where class_id = p_class_id and member_id = p_member_id; + + return query + with inserted as ( + insert into public.classbot_member_timetable( + class_id, member_id, weekday, period, subject, activity, teacher, room, memo, effective_from, effective_to + ) + select + p_class_id, + p_member_id, + row_data.weekday, + row_data.period, + trim(row_data.subject), + coalesce(trim(row_data.activity), ''), + coalesce(trim(row_data.teacher), ''), + coalesce(trim(row_data.room), ''), + coalesce(trim(row_data.memo), ''), + coalesce(row_data.effective_from, current_date), + row_data.effective_to + from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data( + weekday integer, + period integer, + subject text, + activity text, + teacher text, + room text, + memo text, + effective_from date, + effective_to date + ) + returning * + ) + select * from inserted order by weekday, period, effective_from desc; +end; +$$; + +revoke all on function public.classbot_replace_member_timetable(uuid, uuid, jsonb) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_replace_member_timetable(uuid, uuid, jsonb) to service_role'; + end if; +end; +$$; + +drop trigger if exists set_updated_at on public.classbot_member_timetable; +create trigger set_updated_at +before update on public.classbot_member_timetable +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_member_timetable enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 3, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; diff --git a/apps/classbot/db/migrations/004_portal_invite_channel.sql b/apps/classbot/db/migrations/004_portal_invite_channel.sql new file mode 100644 index 0000000..5987918 --- /dev/null +++ b/apps/classbot/db/migrations/004_portal_invite_channel.sql @@ -0,0 +1,9 @@ +-- Classbot schema v4: one invite may be claimed once by Kakao and once by the +-- student web portal. The two channels retain independent one-time markers. + +alter table public.classbot_invites + add column if not exists portal_used_at timestamptz; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 4, 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 c3a0724..bd0dee6 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, 1, now()) +values (1, 4, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -171,9 +171,12 @@ create table if not exists public.classbot_invites ( code_hash text not null unique, expires_at timestamptz not null, used_at timestamptz, + portal_used_at timestamptz, created_at timestamptz not null default now() ); +alter table public.classbot_invites add column if not exists portal_used_at timestamptz; + drop function if exists public.classbot_claim_invite(text, text, text); create or replace function public.classbot_claim_invite( @@ -259,6 +262,50 @@ create table if not exists public.classbot_timetable ( unique (class_id, weekday, period, effective_from) ); +-- Individual timetables are kept separate from the class-wide fallback. The +-- composite member key prevents a service-side bug from attaching a member's +-- timetable to a different class. +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'classbot_members_class_id_id_key' + and conrelid = 'public.classbot_members'::regclass + ) then + alter table public.classbot_members + add constraint classbot_members_class_id_id_key unique (class_id, id); + end if; +end; +$$; + +create table if not exists public.classbot_member_timetable ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null, + member_id uuid not null, + weekday integer not null check (weekday between 1 and 5), + period integer not null check (period between 1 and 12), + subject text not null check (char_length(trim(subject)) between 1 and 100), + activity text not null default '' check (char_length(activity) <= 300), + teacher text not null default '' check (char_length(teacher) <= 100), + room text not null default '' check (char_length(room) <= 100), + memo text not null default '' check (char_length(memo) <= 500), + effective_from date not null default current_date, + effective_to date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint classbot_member_timetable_member_fkey + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) + on delete cascade, + constraint classbot_member_timetable_effective_dates_check + check (effective_to is null or effective_to >= effective_from), + constraint classbot_member_timetable_slot_key + unique (class_id, member_id, weekday, period, effective_from) +); + +create index if not exists classbot_member_timetable_lookup_idx + on public.classbot_member_timetable(class_id, member_id, weekday, effective_from, effective_to, period); + create or replace function public.classbot_replace_timetable_day( p_class_id uuid, p_weekday integer, @@ -323,6 +370,110 @@ begin end; $$; +create or replace function public.classbot_replace_member_timetable( + p_class_id uuid, + p_member_id uuid, + p_rows jsonb +) +returns setof public.classbot_member_timetable +language plpgsql +security definer +set search_path = public +as $$ +begin + if jsonb_typeof(coalesce(p_rows, '[]'::jsonb)) <> 'array' then + raise exception '개인 시간표 데이터는 배열이어야 합니다.'; + end if; + + perform 1 + from public.classbot_members + where class_id = p_class_id + and id = p_member_id + and status <> 'left' + for update; + if not found then + raise exception '개인 시간표를 등록할 구성원을 찾을 수 없습니다.'; + end if; + + if exists ( + select 1 + from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data( + weekday integer, + period integer, + subject text, + activity text, + teacher text, + room text, + memo text, + effective_from date, + effective_to date + ) + where row_data.weekday is null + or row_data.weekday < 1 + or row_data.weekday > 5 + or row_data.period is null + or row_data.period < 1 + or row_data.period > 12 + or nullif(trim(row_data.subject), '') is null + or char_length(trim(row_data.subject)) > 100 + or char_length(coalesce(row_data.activity, '')) > 300 + or char_length(coalesce(row_data.teacher, '')) > 100 + or char_length(coalesce(row_data.room, '')) > 100 + or char_length(coalesce(row_data.memo, '')) > 500 + or ( + row_data.effective_to is not null + and row_data.effective_to < coalesce(row_data.effective_from, current_date) + ) + ) then + raise exception '개인 시간표 행 형식이 올바르지 않습니다.'; + end if; + + delete from public.classbot_member_timetable + where class_id = p_class_id and member_id = p_member_id; + + return query + with inserted as ( + insert into public.classbot_member_timetable( + class_id, member_id, weekday, period, subject, activity, teacher, room, memo, effective_from, effective_to + ) + select + p_class_id, + p_member_id, + row_data.weekday, + row_data.period, + trim(row_data.subject), + coalesce(trim(row_data.activity), ''), + coalesce(trim(row_data.teacher), ''), + coalesce(trim(row_data.room), ''), + coalesce(trim(row_data.memo), ''), + coalesce(row_data.effective_from, current_date), + row_data.effective_to + from jsonb_to_recordset(coalesce(p_rows, '[]'::jsonb)) as row_data( + weekday integer, + period integer, + subject text, + activity text, + teacher text, + room text, + memo text, + effective_from date, + effective_to date + ) + returning * + ) + select * from inserted order by weekday, period, effective_from desc; +end; +$$; + +revoke all on function public.classbot_replace_member_timetable(uuid, uuid, jsonb) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_replace_member_timetable(uuid, uuid, jsonb) to service_role'; + end if; +end; +$$; + create table if not exists public.classbot_events ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -388,6 +539,34 @@ create unique index if not exists classbot_notices_request_key_idx on public.classbot_notices(class_id, request_key) where request_key is not null; +create table if not exists public.classbot_files ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid references public.classbot_members(id) on delete cascade, + alias text not null check (char_length(alias) between 1 and 60), + filename text not null check (char_length(filename) between 1 and 180), + description text not null default '' check (char_length(description) <= 1000), + mime_type text not null check (mime_type in ('application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif')), + size_bytes bigint not null check (size_bytes between 1 and 20971520), + bucket text not null, + object_path text not null unique, + status text not null default 'active' check (status in ('active', 'deleted')), + created_by text not null default 'admin', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists classbot_files_class_alias_idx + on public.classbot_files(class_id, lower(alias)) + where member_id is null and status = 'active'; + +create unique index if not exists classbot_files_member_alias_idx + on public.classbot_files(class_id, member_id, lower(alias)) + where member_id is not null and status = 'active'; + +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_notifications ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -445,8 +624,10 @@ begin 'classbot_classes', 'classbot_members', 'classbot_timetable', + 'classbot_member_timetable', 'classbot_events', 'classbot_notices', + 'classbot_files', 'classbot_notifications' ] loop @@ -464,8 +645,10 @@ alter table public.classbot_schema_meta enable row level security; alter table public.classbot_members enable row level security; alter table public.classbot_invites enable row level security; alter table public.classbot_timetable enable row level security; +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_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 69372ab..12d1831 100644 --- a/apps/classbot/docs/deployment.md +++ b/apps/classbot/docs/deployment.md @@ -27,10 +27,11 @@ 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_replace_timetable_day(uuid,integer,jsonb)') is not null as timetable_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 `1`과 모든 RPC의 `true`다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. +기대값은 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로 접근한다. ## 3. 기존 Render 서비스 설정 @@ -68,7 +69,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. 개발봇 또는 테스트 채널에서 `오늘 일정 학생 1`처럼 이름을 맨 뒤에 붙인 조회와 개인 일정 격리를 확인한다. +5. 개발봇 또는 테스트 채널에서 `이름등록 초대코드`를 한 번 실행한 뒤 `오늘 일정`, `시간표 전체`, `자료 목록`을 이름 없이 조회하고 개인 일정·개인 자료 격리를 확인한다. + +학생 웹 포털은 `https://quilolab.com/schedule/`에서 이름과 같은 일회용 초대 코드로 최초 로그인한다. 학생은 본인 일정만 추가할 수 있고 관리자 역할만 반 전체 공개 범위를 선택할 수 있는지 확인한다. 자동 알림을 추가로 켜는 경우에만 비즈니스 채널 인증, 비즈앱, 카카오 로그인, 월렛과 Event 블록을 준비하고 이벤트명을 `quilo_schedule_notification`으로 설정한다. 테스트 구성원 한 명만 활성 수신자로 둔 상태에서 `KAKAO_EVENT_ENABLED=true`로 바꾸고 한 건만 시험 발송한다. POST 접수 후 task 결과가 `sent`로 확정되면 운영 Cron을 활성화하고, 실패하면 즉시 `KAKAO_EVENT_ENABLED=false`로 되돌린다. diff --git a/apps/classbot/package-lock.json b/apps/classbot/package-lock.json index e34934e..97a43cd 100644 --- a/apps/classbot/package-lock.json +++ b/apps/classbot/package-lock.json @@ -14,6 +14,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", + "multer": "^2.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1417,6 +1418,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1522,6 +1529,23 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1669,6 +1693,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", @@ -2875,6 +2914,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -3111,6 +3212,20 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3389,6 +3504,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3551,6 +3683,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3591,6 +3729,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/apps/classbot/package.json b/apps/classbot/package.json index 94089e9..6194d83 100644 --- a/apps/classbot/package.json +++ b/apps/classbot/package.json @@ -28,6 +28,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", + "multer": "^2.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/apps/classbot/server/README.md b/apps/classbot/server/README.md index 8cebef6..149809a 100644 --- a/apps/classbot/server/README.md +++ b/apps/classbot/server/README.md @@ -19,4 +19,6 @@ Kakao Event API의 POST `SUCCESS`는 접수 성공일 뿐 실제 발송 성공 운영 환경은 `CLASSBOT_STORAGE=supabase`만 허용한다. 메모리 저장소는 재시작 시 데이터와 중복 방지 기록을 잃으므로 로컬 개발 전용이다. 세션·Cron·카카오 스킬 secret은 각각 32자 이상, 관리자 비밀번호는 16자 이상이어야 한다. 카카오 스킬 secret은 스킬 URL의 `?secret=` 또는 `X-Classbot-Skill-Secret` 헤더로 전달한다. -구성원 생성과 시간표 요일 교체는 Supabase RPC 안에서 학급 행을 잠그고 처리한다. 관리자 API의 구성원 DTO와 audit JSON에는 `kakao_user_key` 원문을 반환하거나 기록하지 않는다. +구성원 생성, 반 시간표 요일 교체, 개인별 시간표 전체 교체는 Supabase RPC 안에서 대상 행을 잠그고 처리한다. 개인별 시간표 테이블은 RLS를 켜고 anon/authenticated 정책을 만들지 않는다. 관리자 API의 구성원 DTO와 audit JSON에는 `kakao_user_key` 원문을 반환하거나 기록하지 않는다. + +학생 포털 최초 로그인은 등록 이름과 일회용 초대 코드를 함께 검증한다. 같은 초대 코드는 카카오 이름 등록과 웹 포털에서 채널별로 한 번씩만 사용할 수 있다. 성공 시 30일 HMAC HttpOnly 쿠키를 발급하며, 학생 요청의 `member_id`는 쿠키의 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. diff --git a/apps/classbot/server/app.js b/apps/classbot/server/app.js index d6de824..8f3c6ed 100644 --- a/apps/classbot/server/app.js +++ b/apps/classbot/server/app.js @@ -4,9 +4,18 @@ import { fileURLToPath } from "node:url"; import cookieSession from "cookie-session"; import express from "express"; import helmet from "helmet"; +import multer from "multer"; 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 { + createPortalToken, + portalCookieOptions, + PORTAL_COOKIE_NAME, + readPortalCookie, + verifyPortalToken, +} from "./services/portal-session.js"; import { KakaoEventClient, simpleTextResponse } from "./services/kakao.js"; import { handleKakaoCommand } from "./services/commands.js"; import { NotificationService } from "./services/notifications.js"; @@ -18,6 +27,9 @@ const CATEGORIES = new Set(["assessment", "assignment", "class", "schedule_chang const EVENT_STATUSES = new Set(["scheduled", "completed", "cancelled"]); const NOTICE_STATUSES = new Set(["draft", "published", "archived"]); const MEMBER_STATUSES = new Set(["invited", "active", "disabled", "left"]); +const FILE_MIME_TYPES = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"]); +const MAX_FILE_BYTES = 20 * 1024 * 1024; +const PORTAL_LOGIN_ERROR = "이름을 확인할 수 없습니다."; class HttpError extends Error { constructor(status, message) { @@ -41,6 +53,44 @@ function optionalString(value, label, maxLength = 1000) { return text; } +function detectedFileMime(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4) return null; + if (buffer.subarray(0, 1024).includes(Buffer.from("%PDF-"))) return "application/pdf"; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png"; + if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif"; + if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; + return null; +} + +function publicFile(file) { + const { bucket: _bucket, object_path: _objectPath, created_by: _createdBy, ...safe } = file; + return { ...safe, visibility: safe.member_id ? "private" : "class" }; +} + +function safeFileName(value) { + return String(value || "file").replace(/[\r\n\0"]/g, "_").slice(0, 180) || "file"; +} + +function sendFileBody(res, file, body, { attachment = false } = {}) { + const filename = safeFileName(file.filename); + res.set({ + "Content-Type": file.mime_type, + "Content-Length": String(body.length), + "Content-Disposition": `${attachment ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(filename)}`, + "Cache-Control": "private, no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }); + return res.send(body); +} + +function fileLink(req, token, { download = false } = {}) { + const basePath = String(req.baseUrl || "").replace(/\/$/, ""); + const url = new URL(`${basePath}/api/files/${encodeURIComponent(token)}`, `${req.protocol}://${req.get("host")}`); + if (download) url.searchParams.set("download", "1"); + return url.toString(); +} + function booleanValue(value, fallback) { if (value === undefined) return fallback; if (typeof value !== "boolean") throw new HttpError(400, "켜기/끄기 값은 boolean이어야 합니다."); @@ -185,6 +235,33 @@ function publicMembers(items) { return items.map(publicMember); } +function portalMember(member) { + return { id: member.id, display_name: member.display_name, role: member.role }; +} + +function portalRange(query) { + const result = {}; + for (const key of ["from", "to"]) { + const value = query[key]; + if (value == null || value === "") continue; + if (typeof value !== "string" || value.length > 40) { + throw new HttpError(400, "올바른 일정 조회 기간을 입력해 주세요."); + } + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value) + ? `${value}T${key === "from" ? "00:00:00.000" : "23:59:59.999"}` + : value; + try { + result[key] = parseKoreaDateTime(normalized).toISOString(); + } catch { + throw new HttpError(400, "올바른 일정 조회 기간을 입력해 주세요."); + } + } + if (result.from && result.to && new Date(result.from) > new Date(result.to)) { + throw new HttpError(400, "일정 조회 시작은 종료보다 늦을 수 없습니다."); + } + return result; +} + function createLoginLimiter() { const attempts = new Map(); return (req, _res, next) => { @@ -199,6 +276,21 @@ function createLoginLimiter() { }; } +function createPortalLoginLimiter() { + const attempts = new Map(); + return (req, _res, next) => { + const key = req.ip || "unknown"; + const current = Date.now(); + const recent = (attempts.get(key) || []).filter((time) => current - time < 15 * 60_000); + if (recent.length >= 10) { + return next(new HttpError(429, "로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.")); + } + recent.push(current); + attempts.set(key, recent); + return next(); + }; +} + export async function createApp(options = {}) { const config = options.config || loadConfig(); const store = options.store || (await createStore(config)); @@ -208,6 +300,70 @@ export async function createApp(options = {}) { const embedded = options.embedded === true; const app = express(); const origins = allowedOrigins(config.allowedOrigin); + const portalCookie = portalCookieOptions({ embedded, production: config.production }); + const clearPortalCookie = (res) => { + const { maxAge: _maxAge, ...options } = portalCookie; + res.clearCookie(PORTAL_COOKIE_NAME, options); + }; + const setPortalCookie = (res, token) => res.cookie(PORTAL_COOKIE_NAME, token, portalCookie); + const resolvePortalMember = async (req) => { + const token = readPortalCookie(req.get("cookie")); + if (!token) return null; + let verified; + try { + verified = verifyPortalToken(token, config.sessionSecret, { now: now() }); + } catch { + return null; + } + const member = await store.getMember(verified.memberId); + return member && !new Set(["left", "disabled"]).has(member.status) ? member : null; + }; + const requirePortal = asyncRoute(async (req, _res, next) => { + const member = await resolvePortalMember(req); + if (!member) throw new HttpError(401, "학생 포털 로그인이 필요합니다."); + req.portalMember = member; + return next(); + }); + const portalCanManageEvent = (member, event) => Boolean( + event + && (event.member_id === member.id || (member.role === "admin" && event.member_id == null)), + ); + const portalFile = (req, file, issuedAt) => { + const token = createFileToken(file.id, config.sessionSecret, { now: issuedAt, ttlSeconds: 15 * 60 }); + return { + id: file.id, + alias: file.alias, + filename: file.filename, + description: file.description, + mime_type: file.mime_type, + size_bytes: file.size_bytes, + member_id: file.member_id || null, + visibility: file.member_id ? "private" : "class", + created_at: file.created_at, + open_url: fileLink(req, token), + download_url: fileLink(req, token, { download: true }), + links_expire_at: new Date(issuedAt.getTime() + 15 * 60_000).toISOString(), + }; + }; + const fileForAdmin = (req, file) => { + const safe = publicFile(file); + if (file.member_id) return safe; + const issuedAt = now(); + return { + ...safe, + share_url: fileLink(req, createFileToken(file.id, config.sessionSecret, { now: issuedAt })), + share_expires_at: new Date(issuedAt.getTime() + 15 * 60_000).toISOString(), + }; + }; + const fileUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_BYTES, files: 1, fields: 8, fieldSize: 4 * 1024 }, + }).single("file"); + const receiveFile = (req, res, next) => fileUpload(req, res, (error) => { + if (!error) return next(); + if (error.code === "LIMIT_FILE_SIZE") return next(new HttpError(413, "파일은 20MB 이하여야 합니다.")); + return next(new HttpError(400, "파일 업로드 형식이 올바르지 않습니다.")); + }); if (config.production) app.set("trust proxy", 1); app.disable("x-powered-by"); @@ -244,8 +400,8 @@ export async function createApp(options = {}) { res.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); } if (req.method === "OPTIONS") return originAllowed ? res.sendStatus(204) : res.sendStatus(403); - if (origin && !originAllowed && req.path.startsWith("/api/admin")) { - return next(new HttpError(403, "허용되지 않은 관리자 화면에서 보낸 요청입니다.")); + if (origin && !originAllowed && (req.path.startsWith("/api/admin") || req.path.startsWith("/api/portal"))) { + return next(new HttpError(403, "허용되지 않은 화면에서 보낸 요청입니다.")); } return next(); }); @@ -275,6 +431,127 @@ export async function createApp(options = {}) { } })); + app.get("/api/files/:token", asyncRoute(async (req, res) => { + let verified; + try { + verified = verifyFileToken(req.params.token, config.sessionSecret, { now: now() }); + } catch (error) { + throw new HttpError(/만료/.test(error.message) ? 410 : 403, error.message); + } + const file = await store.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" }); + })); + + app.post("/api/portal/login", createPortalLoginLimiter(), asyncRoute(async (req, res) => { + 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 + ? await store.listMembers() + : []; + const matches = members.filter((member) => member.display_name === displayName); + if (matches.length !== 1 || new Set(["left", "disabled"]).has(matches[0].status)) { + throw new HttpError(401, PORTAL_LOGIN_ERROR); + } + let member; + try { + member = await store.claimPortalInvite({ memberId: matches[0].id, code: inviteCode }); + } catch { + throw new HttpError(401, PORTAL_LOGIN_ERROR); + } + if (!member || new Set(["left", "disabled"]).has(member.status)) { + throw new HttpError(401, PORTAL_LOGIN_ERROR); + } + setPortalCookie(res, createPortalToken(member.id, config.sessionSecret, { now: now() })); + res.json({ authenticated: true, member: portalMember(member) }); + })); + + 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 }); + } + return res.json({ authenticated: true, member: portalMember(member) }); + })); + + app.post("/api/portal/logout", (req, res) => { + clearPortalCookie(res); + res.json({ authenticated: false }); + }); + + app.get("/api/portal/overview", requirePortal, asyncRoute(async (req, res) => { + const range = portalRange(req.query || {}); + const timetablePromise = (async () => { + if (typeof store.listMemberTimetable === "function") { + const memberTimetable = await store.listMemberTimetable(req.portalMember.id, { date: range.from || now() }); + if (Array.isArray(memberTimetable) && memberTimetable.length) return memberTimetable; + } + return store.listTimetable(); + })(); + const [classroom, timetable, events, notices] = await Promise.all([ + store.getClassroom(), + timetablePromise, + store.listEvents({ ...range, targetMemberId: req.portalMember.id }), + store.listNotices({ status: "published", limit: 50 }), + ]); + res.json({ + member: portalMember(req.portalMember), + classroom, + timetable, + notices: notices.filter((notice) => notice.status === "published"), + events: events.filter((event) => event.member_id == null || event.member_id === req.portalMember.id), + }); + })); + + app.get("/api/portal/files", requirePortal, asyncRoute(async (req, res) => { + const issuedAt = now(); + const files = await store.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)) + .map((file) => portalFile(req, file, issuedAt)), + }); + })); + + app.post("/api/portal/events", requirePortal, asyncRoute(async (req, res) => { + const requestedScope = String(req.body?.scope || "personal"); + const scope = requestedScope === "self" ? "personal" : requestedScope; + if (!new Set(["personal", "class"]).has(scope)) throw new HttpError(400, "올바른 일정 공개 범위를 선택해 주세요."); + if (scope === "class" && req.portalMember.role !== "admin") { + throw new HttpError(403, "반 전체 일정은 학급 관리자만 등록할 수 있습니다."); + } + const memberId = scope === "class" ? null : req.portalMember.id; + if (req.body?.member_id !== undefined && (req.body.member_id || null) !== memberId) { + throw new HttpError(403, "다른 구성원의 일정은 등록할 수 없습니다."); + } + const input = { + ...eventInput({ ...(req.body || {}), member_id: memberId }), + request_key: requestKey(req), + }; + const item = await store.createEvent(input, `portal:${req.portalMember.id}`); + res.status(201).json({ item }); + })); + + app.patch("/api/portal/events/:id", requirePortal, asyncRoute(async (req, res) => { + const current = await store.getEvent(req.params.id); + if (!portalCanManageEvent(req.portalMember, current)) throw new HttpError(404, "일정을 찾을 수 없습니다."); + if (req.body?.member_id !== undefined || req.body?.scope !== undefined) { + throw new HttpError(400, "학생 포털에서는 일정 공개 범위를 변경할 수 없습니다."); + } + const input = eventInput(req.body || {}, { partial: true }); + if (!Object.keys(input).length) throw new HttpError(400, "변경할 일정 내용을 입력해 주세요."); + const item = await store.updateEvent(req.params.id, input, `portal:${req.portalMember.id}`); + res.json({ item }); + })); + + app.delete("/api/portal/events/:id", requirePortal, asyncRoute(async (req, res) => { + const current = await store.getEvent(req.params.id); + if (!portalCanManageEvent(req.portalMember, current)) throw new HttpError(404, "일정을 찾을 수 없습니다."); + const item = await store.cancelEvent(req.params.id, `portal:${req.portalMember.id}`); + res.json({ item }); + })); + const isAdminRequest = (req) => embedded ? req.classbotExternalAdmin === true : req.session?.isAdmin === true; @@ -299,6 +576,49 @@ export async function createApp(options = {}) { return requireAdmin(req, res, next); }); + app.get("/api/admin/files", asyncRoute(async (req, res) => { + res.json({ items: (await store.listFiles({ all: true })).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 또는 이미지 파일을 선택해 주세요."); + const mimeType = detectedFileMime(req.file.buffer); + if (!mimeType || !FILE_MIME_TYPES.has(mimeType)) { + throw new HttpError(400, "PDF, JPEG, PNG, WebP, GIF 파일만 올릴 수 있습니다."); + } + const visibility = String(req.body?.visibility || (req.body?.member_id ? "private" : "class")); + if (!new Set(["class", "private"]).has(visibility)) throw new HttpError(400, "자료 공개 범위가 올바르지 않습니다."); + let memberId = req.body?.member_id ? requireString(req.body.member_id, "개인 자료 구성원", 128) : null; + if (visibility === "private" && !memberId) throw new HttpError(400, "개인 자료를 받을 구성원을 선택해 주세요."); + if (visibility === "class" && memberId) throw new HttpError(400, "반 전체 자료에는 개인 구성원을 지정할 수 없습니다."); + if (memberId) { + const member = await store.getMember(memberId); + if (!member || member.status === "left") throw new HttpError(400, "개인 자료를 받을 구성원을 찾을 수 없습니다."); + } + const item = await store.createFile({ + member_id: memberId, + alias: requireString(req.body?.alias, "자료 별칭", 60), + filename: requireString(req.file.originalname, "파일 이름", 180), + description: optionalString(req.body?.description, "자료 설명", 1000), + mime_type: mimeType, + }, req.file.buffer, "admin"); + res.status(201).json({ item: fileForAdmin(req, item) }); + })); + app.patch("/api/admin/files/:id", asyncRoute(async (req, res) => { + const patch = {}; + 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")) }); + })); + app.get("/api/admin/files/:id/download", asyncRoute(async (req, res) => { + const file = await store.getFile(req.params.id); + if (!file || file.status !== "active") throw new HttpError(404, "파일을 찾을 수 없습니다."); + sendFileBody(res, file, await store.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")) }); + })); + app.get("/api/admin/overview", asyncRoute(async (_req, res) => { const [classroom, members, timetable, events, notices, notificationItems] = await Promise.all([ store.getClassroom(), @@ -376,6 +696,18 @@ export async function createApp(options = {}) { res.status(201).json({ item: { id: item.id, member_id: item.member_id, expires_at: item.expires_at }, code }); })); + app.get("/api/admin/members/:id/timetable", asyncRoute(async (req, res) => { + const weekday = req.query.weekday === undefined ? undefined : Number(req.query.weekday); + if (weekday !== undefined && (!Number.isInteger(weekday) || weekday < 1 || weekday > 5)) { + throw new HttpError(400, "요일은 1~5 사이여야 합니다."); + } + res.json({ items: await store.listMemberTimetable(req.params.id, { weekday, date: req.query.date || now() }) }); + })); + app.put("/api/admin/members/:id/timetable", asyncRoute(async (req, res) => { + if (!Array.isArray(req.body?.rows)) throw new HttpError(400, "개인 시간표 rows 배열이 필요합니다."); + res.json({ items: await store.replaceMemberTimetable({ memberId: req.params.id, rows: req.body.rows }, "admin") }); + })); + app.get("/api/admin/timetable", asyncRoute(async (req, res) => { const weekday = req.query.weekday === undefined ? undefined : Number(req.query.weekday); if (weekday !== undefined && (!Number.isInteger(weekday) || weekday < 1 || weekday > 5)) throw new HttpError(400, "요일은 1~5 사이여야 합니다."); @@ -482,7 +814,12 @@ export async function createApp(options = {}) { const supplied = req.get("x-classbot-skill-secret") || req.query.secret; if (!safeEqual(supplied, config.kakaoSkillSecret)) return res.status(401).json(simpleTextResponse("인증되지 않은 스킬 요청입니다.")); } - res.json(await handleKakaoCommand({ payload: req.body || {}, store, now: now() })); + res.json(await handleKakaoCommand({ + payload: req.body || {}, + store, + now: now(), + makeFileUrl: (file) => fileLink(req, createFileToken(file.id, config.sessionSecret, { now: now() })), + })); })); app.post("/api/cron/notifications", createCronGuard(config.cronSecret), asyncRoute(async (req, res) => { diff --git a/apps/classbot/server/app.test.js b/apps/classbot/server/app.test.js index 65756f0..bfaa4b8 100644 --- a/apps/classbot/server/app.test.js +++ b/apps/classbot/server/app.test.js @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import request from "supertest"; import { createApp } from "./app.js"; +import { hashInviteCode } from "./security.js"; +import { createFileToken } from "./services/file-tokens.js"; import { MemoryStore } from "./store/memory-store.js"; const config = { @@ -35,6 +37,20 @@ async function login(agent) { assert.equal(response.status, 200); } +async function issuePortalInvite(store, member, code = "ABCD-EFGH") { + await store.createInvite({ + memberId: member.id, + codeHash: hashInviteCode(code), + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + }); + return code; +} + +async function loginPortal(agent, store, member, code = "ABCD-EFGH") { + await issuePortalInvite(store, member, code); + return agent.post("/api/portal/login").send({ display_name: member.display_name, invite_code: code }); +} + test("관리자 세션이 보호되고 로그인 후 overview를 조회한다", async () => { const { app, agent } = await fixture(); assert.equal((await request(app).get("/api/admin/overview")).status, 401); @@ -50,6 +66,309 @@ test("관리자 세션이 보호되고 로그인 후 overview를 조회한다", assert.equal(Array.isArray(response.body.notices), true); }); +test("학생 포털은 초대 코드로 최초 로그인하고 HMAC 쿠키 세션을 유지·해제한다", async () => { + const { app, store } = await fixture(); + const member = (await store.listMembers()).find((item) => item.role === "student"); + const code = await issuePortalInvite(store, member); + + const anonymous = await request(app).get("/api/portal/session"); + assert.deepEqual(anonymous.body, { authenticated: false }); + assert.equal((await request(app).get("/api/portal/overview")).status, 401); + assert.equal((await request(app).get("/api/portal/files")).status, 401); + + const wrongCase = await request(app) + .post("/api/portal/login") + .send({ display_name: `${member.display_name}님`, invite_code: code }); + assert.equal(wrongCase.status, 401); + assert.equal(wrongCase.body.error, "이름을 확인할 수 없습니다."); + + const agent = request.agent(app); + const response = await agent + .post("/api/portal/login") + .send({ display_name: member.display_name, invite_code: code }); + assert.equal(response.status, 200); + assert.deepEqual(response.body.member, { id: member.id, display_name: member.display_name, role: "student" }); + const cookie = response.headers["set-cookie"].find((item) => item.startsWith("classbot_portal=")); + assert.match(cookie, /; Path=\/;/); + assert.match(cookie, /; HttpOnly/); + assert.match(cookie, /; SameSite=Lax/); + assert.doesNotMatch(cookie, /; Secure/); + + const session = await agent.get("/api/portal/session"); + assert.deepEqual(session.body, { authenticated: true, member: response.body.member }); + const reused = await request(app) + .post("/api/portal/login") + .send({ display_name: member.display_name, invite_code: code }); + assert.equal(reused.status, 401); + assert.equal(reused.body.error, wrongCase.body.error); + + assert.equal((await agent.post("/api/portal/logout")).status, 200); + assert.deepEqual((await agent.get("/api/portal/session")).body, { authenticated: false }); +}); + +test("같은 초대 코드는 카카오와 웹 포털에서 채널별로 한 번씩 사용할 수 있다", async () => { + const { app, store } = await fixture(); + const [first, second] = (await store.listMembers()).filter((item) => item.role === "student").slice(0, 2); + + const portalFirstCode = await issuePortalInvite(store, first, "PORT-AL01"); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: first.display_name, invite_code: portalFirstCode })).status, 200); + const kakaoAfterPortal = await store.claimInvite({ code: portalFirstCode, userKey: "kakao-after-portal" }); + assert.equal(kakaoAfterPortal.id, first.id); + await assert.rejects(() => store.claimInvite({ code: portalFirstCode, userKey: "kakao-reuse" }), /올바르지 않거나 만료/); + + const kakaoFirstCode = await issuePortalInvite(store, second, "KAKA-O001"); + const kakaoFirst = await store.claimInvite({ code: kakaoFirstCode, userKey: "kakao-before-portal" }); + assert.equal(kakaoFirst.id, second.id); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: second.display_name, invite_code: kakaoFirstCode })).status, 200); + assert.equal((await request(app).post("/api/portal/login").send({ display_name: second.display_name, invite_code: kakaoFirstCode })).status, 401); +}); + +test("학생 포털 로그인 실패는 이름 사칭·중복·비활성 여부를 같은 오류로 숨기고 요청 횟수를 제한한다", async () => { + const { app, store } = await fixture(); + const members = await store.listMembers(); + const first = members.find((item) => item.role === "student"); + const second = members.find((item) => item.role === "student" && item.id !== first.id); + const code = await issuePortalInvite(store, first, "JKLM-NPQR"); + + const impersonation = await request(app) + .post("/api/portal/login") + .send({ display_name: second.display_name, invite_code: code }); + assert.equal(impersonation.status, 401); + assert.deepEqual(impersonation.body, { error: "이름을 확인할 수 없습니다." }); + assert.equal(JSON.stringify(impersonation.body).includes(first.display_name), false); + assert.equal(JSON.stringify(impersonation.body).includes(second.display_name), false); + + store.members.find((item) => item.id === second.id).display_name = first.display_name; + const duplicate = await request(app) + .post("/api/portal/login") + .send({ display_name: first.display_name, invite_code: code }); + assert.equal(duplicate.status, 401); + assert.deepEqual(duplicate.body, impersonation.body); + + const limitedFixture = await fixture(); + for (let index = 0; index < 10; index += 1) { + const failed = await request(limitedFixture.app) + .post("/api/portal/login") + .send({ display_name: `없는 학생 ${index}`, invite_code: "BAD-CODE" }); + assert.equal(failed.status, 401); + } + const limited = await request(limitedFixture.app) + .post("/api/portal/login") + .send({ display_name: "없는 학생", invite_code: "BAD-CODE" }); + assert.equal(limited.status, 429); + assert.match(limited.body.error, /로그인 시도가 너무 많습니다/); + assert.equal(JSON.stringify(limited.body).includes("학생 1"), false); +}); + +test("학생 포털 overview는 반 전체와 본인 일정·게시 공지만 반환하고 구성원별 시간표 adapter를 사용한다", async () => { + const { app, store } = await fixture(); + const students = (await store.listMembers()).filter((item) => item.role === "student"); + const member = students[0]; + const other = students[1]; + await store.createEvent({ category: "class", title: "반 전체 행사", due_at: "2026-07-20T10:00:00" }); + await store.createEvent({ member_id: member.id, category: "assignment", title: "내 개인 일정", due_at: "2026-07-21T10:00:00" }); + await store.createEvent({ member_id: other.id, category: "assignment", title: "다른 학생 비밀 일정", due_at: "2026-07-22T10:00:00" }); + await store.createNotice({ title: "초안 공지", body: "노출 금지", status: "draft" }); + let timetableOptions; + store.listMemberTimetable = async (memberId, options) => { + timetableOptions = options; + return [{ period: 1, subject: "개인 선택", member_id: memberId }]; + }; + + const agent = request.agent(app); + assert.equal((await loginPortal(agent, store, member)).status, 200); + const response = await agent.get("/api/portal/overview?from=2026-07-20&to=2026-07-22"); + assert.equal(response.status, 200); + assert.deepEqual(response.body.member, { id: member.id, display_name: member.display_name, role: "student" }); + assert.deepEqual(response.body.timetable, [{ period: 1, subject: "개인 선택", member_id: member.id }]); + assert.equal(timetableOptions.date, "2026-07-19T15:00:00.000Z"); + const titles = response.body.events.map((item) => item.title); + assert.equal(titles.includes("반 전체 행사"), true); + assert.equal(titles.includes("내 개인 일정"), true); + assert.equal(titles.includes("다른 학생 비밀 일정"), false); + assert.equal(response.body.notices.every((item) => item.status === "published"), true); + assert.equal(response.body.notices.some((item) => item.title === "초안 공지"), false); + assert.equal((await agent.get("/api/portal/overview?from=2026-07-23&to=2026-07-20")).status, 400); + assert.equal((await agent.get("/api/portal/overview?from=not-a-date")).status, 400); +}); + +test("관리자 API는 구성원 개인 시간표를 조회하고 원자적으로 교체한다", async () => { + const { app, agent, store } = await fixture(); + const member = (await store.listMembers()).find((item) => item.role === "student"); + await login(agent); + const saved = await agent.put(`/api/admin/members/${member.id}/timetable`).send({ rows: [{ + weekday: 1, + period: 1, + subject: "개인 선택 과목", + teacher: "담당 교사", + room: "선택 강의실", + effective_from: "2026-08-01", + }] }); + assert.equal(saved.status, 200); + assert.equal(saved.body.items[0].member_id, member.id); + const listed = await agent.get(`/api/admin/members/${member.id}/timetable?weekday=1&date=2026-08-01`); + assert.equal(listed.status, 200); + assert.equal(listed.body.items[0].subject, "개인 선택 과목"); + assert.equal((await request(app).put(`/api/admin/members/${member.id}/timetable`).send({ rows: [] })).status, 401); + assert.equal((await agent.put(`/api/admin/members/${member.id}/timetable`).send({ rows: "invalid" })).status, 400); +}); + +test("학생 포털 파일 목록은 반 전체와 본인 자료만 15분 열기·다운로드 URL로 제공한다", async () => { + const { app, store } = await fixture(); + const students = (await store.listMembers()).filter((item) => item.role === "student"); + const member = students[0]; + const classFile = await store.createFile({ + alias: "반 전체 안내", + filename: "guide.pdf", + description: "공개 자료", + mime_type: "application/pdf", + }, Buffer.from("%PDF-1.4\n%%EOF")); + const privateFile = await store.createFile({ + member_id: member.id, + alias: "개인 비밀 자료", + filename: "private.pdf", + description: "노출 금지", + mime_type: "application/pdf", + }, Buffer.from("%PDF-1.4\nprivate\n%%EOF")); + const otherPrivateFile = await store.createFile({ + member_id: students[1].id, + alias: "다른 학생 비밀 자료", + filename: "other-private.pdf", + description: "다른 학생에게만 공개", + mime_type: "application/pdf", + }, Buffer.from("%PDF-1.4\nother-private\n%%EOF")); + + const agent = request.agent(app); + assert.equal((await loginPortal(agent, store, member)).status, 200); + const response = await agent.get("/api/portal/files"); + assert.equal(response.status, 200); + assert.equal(response.body.items.length, 2); + const item = response.body.items.find((entry) => entry.id === classFile.id); + const personalItem = response.body.items.find((entry) => entry.id === privateFile.id); + assert.equal(personalItem.member_id, member.id); + assert.equal(personalItem.visibility, "private"); + assert.equal(JSON.stringify(response.body).includes(otherPrivateFile.id), false); + assert.equal(JSON.stringify(response.body).includes("다른 학생 비밀 자료"), false); + for (const hidden of ["bucket", "object_path", "status", "created_by"]) { + assert.equal(hidden in item, false); + } + const openUrl = new URL(item.open_url); + const downloadUrl = new URL(item.download_url); + assert.match(openUrl.pathname, /^\/api\/files\//); + assert.equal(openUrl.search, ""); + assert.equal(downloadUrl.pathname, openUrl.pathname); + assert.equal(downloadUrl.searchParams.get("download"), "1"); + + const opened = await request(app).get(`${openUrl.pathname}${openUrl.search}`).buffer(true); + assert.equal(opened.status, 200); + assert.match(opened.headers["content-disposition"], /^inline/); + const downloaded = await request(app).get(`${downloadUrl.pathname}${downloadUrl.search}`).buffer(true); + assert.equal(downloaded.status, 200); + assert.match(downloaded.headers["content-disposition"], /^attachment/); +}); + +test("학생 포털 일정 쓰기는 본인 소유를 강제하고 포털 admin만 반 전체 일정을 관리한다", async () => { + const { app, store } = await fixture(); + const members = await store.listMembers(); + const student = members.find((item) => item.role === "student"); + const other = members.find((item) => item.role === "student" && item.id !== student.id); + const portalAdmin = members.find((item) => item.role === "admin"); + const studentAgent = request.agent(app); + const adminAgent = request.agent(app); + assert.equal((await loginPortal(studentAgent, store, student, "STUD-ENT2")).status, 200); + assert.equal((await loginPortal(adminAgent, store, portalAdmin, "ADMN-PORT")).status, 200); + + const own = await studentAgent.post("/api/portal/events").send({ + scope: "personal", + category: "assignment", + title: "학생 본인 일정", + due_at: "2026-07-25T18:00:00", + }); + assert.equal(own.status, 201); + assert.equal(own.body.item.member_id, student.id); + const classDenied = await studentAgent.post("/api/portal/events").send({ + scope: "class", + category: "class", + title: "학생의 반 일정 사칭", + due_at: "2026-07-25T19:00:00", + }); + assert.equal(classDenied.status, 403); + const impersonation = await studentAgent.post("/api/portal/events").send({ + member_id: other.id, + category: "assignment", + title: "다른 학생 일정 사칭", + due_at: "2026-07-25T20:00:00", + }); + assert.equal(impersonation.status, 403); + + const classEvent = await adminAgent.post("/api/portal/events").send({ + scope: "class", + category: "class", + title: "포털 관리자 반 일정", + due_at: "2026-07-26T09:00:00", + }); + assert.equal(classEvent.status, 201); + assert.equal(classEvent.body.item.member_id, null); + assert.equal((await studentAgent.patch(`/api/portal/events/${classEvent.body.item.id}`).send({ title: "학생 변조" })).status, 404); + assert.equal((await studentAgent.delete(`/api/portal/events/${classEvent.body.item.id}`)).status, 404); + + const ownUpdated = await studentAgent.patch(`/api/portal/events/${own.body.item.id}`).send({ title: "학생 본인 일정 수정" }); + assert.equal(ownUpdated.status, 200); + const ownDeleted = await studentAgent.delete(`/api/portal/events/${own.body.item.id}`); + assert.equal(ownDeleted.status, 200); + assert.equal(ownDeleted.body.item.status, "cancelled"); + + const classUpdated = await adminAgent.patch(`/api/portal/events/${classEvent.body.item.id}`).send({ title: "관리자 반 일정 수정" }); + assert.equal(classUpdated.status, 200); + const classDeleted = await adminAgent.delete(`/api/portal/events/${classEvent.body.item.id}`); + assert.equal(classDeleted.status, 200); + assert.equal(classDeleted.body.item.status, "cancelled"); +}); + +test("관리자는 실제 PDF·이미지만 자료실에 올리고 비공개 링크로 내려받는다", async () => { + const { app, agent, store } = await fixture(); + await login(agent); + const pdf = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF"); + const uploaded = await agent + .post("/api/admin/files") + .field("alias", "화학 실험 안내") + .field("description", "실험 전 읽을 자료") + .field("visibility", "class") + .attach("file", pdf, { filename: "화학안내.pdf", contentType: "application/pdf" }); + assert.equal(uploaded.status, 201); + assert.equal(uploaded.body.item.alias, "화학 실험 안내"); + assert.equal(uploaded.body.item.visibility, "class"); + assert.equal("bucket" in uploaded.body.item, false); + assert.equal("object_path" in uploaded.body.item, false); + + const listed = await agent.get("/api/admin/files"); + assert.equal(listed.body.items.length, 1); + const adminDownload = await agent.get(`/api/admin/files/${uploaded.body.item.id}/download`).buffer(true); + assert.equal(adminDownload.status, 200); + assert.match(adminDownload.headers["content-disposition"], /attachment/); + + const token = createFileToken(uploaded.body.item.id, config.sessionSecret, { + now: new Date("2026-07-15T03:00:00.000Z"), + ttlSeconds: 60, + }); + const publicDownload = await request(app).get(`/api/files/${encodeURIComponent(token)}`).buffer(true); + assert.equal(publicDownload.status, 200); + assert.match(publicDownload.headers["content-disposition"], /inline/); + assert.equal(publicDownload.headers["x-content-type-options"], "nosniff"); + + const rejected = await agent + .post("/api/admin/files") + .field("alias", "가짜 PDF") + .field("visibility", "class") + .attach("file", Buffer.from("not a pdf"), { filename: "fake.pdf", contentType: "application/pdf" }); + assert.equal(rejected.status, 400); + assert.equal((await store.listFiles({ all: true })).length, 1); + + const removed = await agent.delete(`/api/admin/files/${uploaded.body.item.id}`); + assert.equal(removed.status, 200); + assert.equal((await request(app).get(`/api/files/${encodeURIComponent(token)}`)).status, 404); +}); + test("health check는 저장소 연결 실패를 503으로 노출하되 내부 오류는 숨긴다", async () => { const store = new MemoryStore(config); store.healthCheck = async () => { throw new Error("database password should not leak"); }; diff --git a/apps/classbot/server/integration-mount.test.js b/apps/classbot/server/integration-mount.test.js index 08b742e..c7a5b7a 100644 --- a/apps/classbot/server/integration-mount.test.js +++ b/apps/classbot/server/integration-mount.test.js @@ -36,6 +36,38 @@ test("기존 Express 4 Quilo 서버의 /schedule namespace에서 API가 동작 const overview = await request(parent).get("/schedule/api/admin/overview"); assert.equal(overview.status, 200); + const uploaded = await request(parent) + .post("/schedule/api/admin/files") + .field("alias", "마운트 자료") + .field("visibility", "class") + .attach("file", Buffer.from("%PDF-1.4\n%%EOF"), { filename: "mount.pdf", contentType: "application/pdf" }); + 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"); + assert.equal(portalSession.body.authenticated, true); + assert.equal(portalSession.body.member.id, student.id); + const portalFiles = await portalAgent.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\//); + assert.match(new URL(portalFiles.body.items[0].download_url).pathname, /^\/schedule\/api\/files\//); + const rootApi = await request(parent).get("/api/health"); assert.equal(rootApi.status, 404); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index 483c957..45a75ee 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -1,4 +1,11 @@ -import { extractKakaoUser, personalizedQuickReplies, simpleTextResponse } from "./kakao.js"; +import { + extractKakaoUser, + personalizedQuickReplies, + registeredQuickReplies, + simpleImageResponse, + simpleTextResponse, + textCardResponse, +} from "./kakao.js"; import { formatEventRows, formatTimetableRows, @@ -8,12 +15,14 @@ import { getWeekEvents, getWeekTimetable, } from "./schedule.js"; -import { dateForSeoulOffset, formatKoreanDate } from "../time.js"; +import { dateForSeoulOffset, formatKoreanDate, getSeoulParts } from "../time.js"; const TRAILING_REQUEST_WORDS = new Set([ "알려줘", "알려주세요", "보여줘", "보여주세요", "확인", "확인해줘", "확인해주세요", "조회", "조회해줘", "조회해주세요", "뭐야", "있어", "있나요", "부탁해", "부탁해요", "해줘", "해주세요", "일정", "시간표", "수업표", "수행평가", "시험", "과제", "숙제", "스케줄", + "전체", "요약", "통합요약", "남은일정", "이번달", "이달", "다음일정", + "자료", "목록", "자료목록", "파일", "pdf", "이미지", ]); function parameterValue(value) { @@ -31,7 +40,7 @@ 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 normalizedText(value) { @@ -45,17 +54,47 @@ function compactText(value) { return normalizedText(value).replace(/\s+/g, "").toLowerCase(); } +function looksLikeFileCommand(command) { + return /^(?:자료\s*(?:목록|리스트)|파일(?:\s|$)|pdf(?:\s|$)|이미지(?:\s|$))/iu.test(normalizedText(command)); +} + +function readFileCommand(command) { + const text = normalizedText(command); + if (/^자료\s*(?:목록|리스트)$/u.test(text)) return { kind: "list" }; + const match = text.match(/^(파일|pdf|이미지)\s+(.+)$/iu); + if (!match) return null; + const requestedType = match[1].toLowerCase() === "pdf" + ? "pdf" + : match[1] === "이미지" + ? "image" + : "file"; + return { kind: "open", requestedType, alias: normalizedText(match[2]) }; +} + function readIntent(command) { const compact = compactText(command); if (compact.includes("시간표") || compact.includes("수업표")) return "timetable"; - if (compact.includes("수행평가") || compact.includes("시험") || compact.includes("테스트")) return "assessment"; - if (compact.includes("과제") || compact.includes("숙제") || compact.includes("제출물")) return "assignment"; + const asksAssessment = compact.includes("수행평가") || compact.includes("시험") || compact.includes("테스트"); + const asksAssignment = compact.includes("과제") || compact.includes("숙제") || compact.includes("제출물"); + if ((asksAssessment && asksAssignment) || compact.includes("통합요약") || compact.includes("학업요약")) return "study-summary"; + if (asksAssessment) return "assessment"; + if (asksAssignment) return "assignment"; if (["일정", "스케줄", "할일", "할것", "뭐있", "해야할"].some((word) => compact.includes(word))) return "schedule"; return null; } function readPeriod(command, intent) { const compact = compactText(command); + if (intent === "timetable" && (compact.includes("전체") || compact.includes("월금"))) { + return { kind: "full-timetable", label: "전체" }; + } + if (compact.includes("다음일정") || compact.includes("가장가까운일정") || compact.includes("제일빠른일정")) { + return { kind: "next", label: "다음" }; + } + if (compact.includes("이번달") || compact.includes("이달")) return { kind: "month", label: "이번 달" }; + if ((compact.includes("이번주") || compact.includes("금주")) && compact.includes("남은")) { + return { kind: "remaining-week", label: "이번 주 남은" }; + } if (compact.includes("다음주") || compact.includes("차주")) return { kind: "week", offset: 1, label: "다음 주" }; if (compact.includes("이번주") || compact.includes("금주") || compact.includes("주간")) return { kind: "week", offset: 0, label: "이번 주" }; if (compact.includes("모레")) return { kind: "day", offset: 2 }; @@ -68,7 +107,7 @@ function appearsAsSeparateName(text, name) { return text === name || text.startsWith(`${name} `) || text.endsWith(` ${name}`) || text.includes(` ${name} `); } -function resolveTargetMember(command, members) { +function resolveTargetMember(command, members, { defaultMember } = {}) { const text = normalizedText(command); const registered = members .filter((member) => member.status !== "left" && String(member.display_name || "").trim()) @@ -80,7 +119,7 @@ function resolveTargetMember(command, members) { } if (matches.length === 1) { const member = matches[0]; - return { member, query: text.slice(0, -member.display_name.length).trim() }; + return { member, query: text.slice(0, -member.display_name.length).trim(), implicit: false }; } const misplaced = registered.find((member) => appearsAsSeparateName(text, member.display_name)); @@ -92,6 +131,7 @@ function resolveTargetMember(command, members) { const tailCompact = compactText(tail); const looksLikeQueryWord = TRAILING_REQUEST_WORDS.has(tailCompact) || ["오늘", "금일", "내일", "명일", "모레", "이번주", "다음주", "금주", "차주", "주간"].includes(tailCompact); + if (looksLikeQueryWord && defaultMember) return { member: defaultMember, query: text, implicit: true }; if (!looksLikeQueryWord && tail.length >= 2 && tail.length <= 40) { throw new Error(`등록된 구성원 '${tail}'을(를) 찾을 수 없습니다. 이름을 확인해 주세요.`); } @@ -108,12 +148,17 @@ function noticeText(notices) { function helpText() { return [ "Quilo에서 이렇게 물어보세요.", - "일정·시간표 조회는 등록된 구성원 이름을 항상 맨 뒤에 붙여 주세요.", - "• 오늘 일정 홍길동 / 내일 일정 홍길동 / 모레 일정 홍길동", - "• 이번 주 일정 홍길동 / 다음 주 시험 홍길동", - "• 오늘 시간표 홍길동 / 숙제 홍길동", + "최초 1회 관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’를 입력해 주세요.", + "등록 후 본인 일정·시간표·자료 조회에는 이름을 붙이지 않아도 됩니다.", + "• 오늘 일정 / 내일 일정 / 다음 일정", + "• 이번 주 남은 일정 / 이번 달 일정", + "• 수행평가 과제 통합 요약 / 다음 주 시험", + "• 오늘 시간표 / 시간표 전체", + "• 자료 목록 / PDF 가정통신문 / 이미지 좌석표", + "다른 구성원의 일정은 기존처럼 질문 맨 뒤에 등록 이름을 붙여 조회할 수 있어요.", + "자료는 카카오 가입이 완료된 본인만 열 수 있어요.", "• 공지", - "• 가입 ABCD-EFGH", + "• 이름등록 ABCD-EFGH (기존 ‘가입 ABCD-EFGH’도 가능)", "• 알림 설정 / 알림 켜기 / 알림 끄기", ].join("\n"); } @@ -121,23 +166,130 @@ function helpText() { function eventKind(intent) { if (intent === "assessment") return { label: "수행평가", category: "assessment", empty: "등록된 수행평가가 없습니다." }; if (intent === "assignment") return { label: "과제", category: "assignment", empty: "등록된 과제가 없습니다." }; + if (intent === "study-summary") { + return { + label: "수행평가·과제 통합 요약", + categories: new Set(["assessment", "assignment"]), + empty: "등록된 수행평가나 과제가 없습니다.", + }; + } return { label: "일정", category: null, empty: "등록된 일정이 없습니다." }; } -async function answerReadQuery({ command, intent, member, store, now }) { +function monthBounds(date) { + const { year, month } = getSeoulParts(date); + const nextYear = month === 12 ? year + 1 : year; + const nextMonth = month === 12 ? 1 : month + 1; + const start = new Date(`${year}-${String(month).padStart(2, "0")}-01T00:00:00+09:00`); + const next = new Date(`${nextYear}-${String(nextMonth).padStart(2, "0")}-01T00:00:00+09:00`); + return { start, end: new Date(next.getTime() - 1), label: `${year}년 ${month}월` }; +} + +function availableFiles(items, memberId) { + return (Array.isArray(items) ? items : []) + .filter((file) => file?.member_id == null || file.member_id === memberId) + .filter((file) => String(file.status || "").toLowerCase() === "active") + .sort((a, b) => String(a.alias || a.filename || "").localeCompare(String(b.alias || b.filename || ""), "ko")); +} + +function isImageFile(file) { + return String(file.mime_type || "").toLowerCase().startsWith("image/"); +} + +function isPdfFile(file) { + return String(file.mime_type || "").toLowerCase() === "application/pdf" + || String(file.filename || "").toLowerCase().endsWith(".pdf"); +} + +function fileSizeLabel(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes < 0) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +function fileListText(files, displayName) { + if (!files.length) return `${displayName}님이 조회할 수 있는 자료가 없습니다.`; + const rows = files.map((file) => { + const type = isImageFile(file) ? "이미지" : isPdfFile(file) ? "PDF" : "파일"; + const scope = file.member_id == null ? "반 전체" : "개인"; + const detail = [file.description, file.filename, fileSizeLabel(file.size_bytes)].filter(Boolean).join(" · "); + return `• [${scope} ${type}] ${file.alias || file.filename}${detail ? `\n ${detail}` : ""}`; + }); + 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); + } + const spec = readFileCommand(command); + if (!spec) return 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 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); + if (typeof makeFileUrl !== "function") { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + + const file = typedMatches[0]; + let url; + try { + url = await makeFileUrl(file); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + const title = file.alias || file.filename || "Quilo 자료"; + if (isImageFile(file) && file.member_id == null) { + try { + return simpleImageResponse({ imageUrl: url, altText: `${title} 이미지` }, replies); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } + } + try { + return textCardResponse({ + title, + description: [file.description, file.filename, fileSizeLabel(file.size_bytes)].filter(Boolean).join("\n"), + url, + buttonLabel: isPdfFile(file) ? "PDF 열기" : "파일 열기", + }, replies); + } catch { + return simpleTextResponse("현재 자료 열기 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); + } +} + +async function answerReadQuery({ command, intent, member, store, now, quickReplies, privateAccess = false }) { const period = readPeriod(command, intent); - const replies = personalizedQuickReplies(member.display_name); + const replies = quickReplies || personalizedQuickReplies(member.display_name); + const targetMemberId = privateAccess ? member.id : undefined; if (intent === "timetable") { + if (period.kind === "full-timetable") { + const bundle = await getWeekTimetable(store, now, { targetMemberId }); + return simpleTextResponse( + `${member.display_name}님의 월~금 전체 시간표\n\n${formatWeekTimetable(bundle)}`, + replies, + ); + } if (period.kind === "week") { - const bundle = await getWeekTimetable(store, now, { weekOffset: period.offset }); + const bundle = await getWeekTimetable(store, now, { weekOffset: period.offset, targetMemberId }); return simpleTextResponse( `${member.display_name}님의 ${period.label} 시간표\n\n${formatWeekTimetable(bundle)}`, replies, ); } const target = dateForSeoulOffset(now, period.offset); - const bundle = await getDaySchedule(store, target, { targetMemberId: member.id }); + const bundle = await getDaySchedule(store, target, { targetMemberId }); return simpleTextResponse( `${member.display_name}님의 ${formatKoreanDate(target)} 시간표\n\n${formatTimetableRows(bundle.timetable)}`, replies, @@ -148,60 +300,126 @@ async function answerReadQuery({ command, intent, member, store, now }) { let rangeLabel; if (period.kind === "day") { const target = dateForSeoulOffset(now, period.offset); - events = (await getDaySchedule(store, target, { targetMemberId: member.id })).events; + events = (await getDaySchedule(store, target, { targetMemberId })).events; rangeLabel = formatKoreanDate(target); } else if (period.kind === "week") { - events = await getWeekEvents(store, now, { weekOffset: period.offset, targetMemberId: member.id }); + events = await getWeekEvents(store, now, { weekOffset: period.offset, targetMemberId }); + rangeLabel = period.label; + } else if (period.kind === "remaining-week") { + events = (await getWeekEvents(store, now, { targetMemberId })) + .filter((event) => new Date(event.due_at).getTime() >= now.getTime()); + rangeLabel = period.label; + } else if (period.kind === "month") { + const bounds = monthBounds(now); + events = await store.listEvents({ + from: bounds.start.toISOString(), + to: bounds.end.toISOString(), + status: "scheduled", + targetMemberId, + }); + rangeLabel = bounds.label; + } else if (period.kind === "next") { + events = await store.listEvents({ + from: now.toISOString(), + status: "scheduled", + targetMemberId, + }); + events = events.slice(0, 1); rangeLabel = period.label; } else { - events = await getUpcomingEvents(store, now, period.days, { targetMemberId: member.id }); + events = await getUpcomingEvents(store, now, period.days, { targetMemberId }); rangeLabel = `앞으로 ${period.days}일`; } + if (!privateAccess) events = events.filter((event) => event.member_id == null); const kind = eventKind(intent); if (kind.category) events = events.filter((event) => event.category === kind.category); + if (kind.categories) events = events.filter((event) => kind.categories.has(event.category)); return simpleTextResponse( `${member.display_name}님의 ${rangeLabel} ${kind.label}\n\n${formatEventRows(events, now, kind.empty)}`, replies, ); } -export async function handleKakaoCommand({ payload, store, now = new Date() }) { +export async function handleKakaoCommand({ payload, store, now = new Date(), makeFileUrl }) { const command = extractCommand(payload); const normalized = compactText(command); const user = extractKakaoUser(payload); let targetDisplayName = ""; + let targetQuickReplies; + let requesterLoaded = false; + let requesterMember = null; + const getRequester = async () => { + if (!requesterLoaded) { + requesterLoaded = true; + requesterMember = user ? await store.findMemberByUserKey(user.id) : null; + } + return requesterMember; + }; try { - if (/^(가입|초대)/.test(command)) { + if (/^(가입|초대|이름등록)/.test(command)) { const code = extractInviteCode(payload, command); if (!user) return simpleTextResponse("카카오 사용자 식별값을 확인할 수 없어 가입할 수 없습니다."); - if (!code) return simpleTextResponse("관리자에게 받은 초대 코드로 ‘가입 ABCD-EFGH’처럼 입력해 주세요."); + if (!code) return simpleTextResponse("관리자에게 받은 코드로 ‘이름등록 ABCD-EFGH’처럼 입력해 주세요. 기존 ‘가입’ 명령도 사용할 수 있습니다."); 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알림을 받으려면 이 카카오톡 채널을 친구 추가해 주세요." : ""; return simpleTextResponse( - `${member.display_name}님, 2학년 4반 가입이 완료되었습니다.\n알림 설정에서 수신 여부를 바꿀 수 있어요.${friendGuide}`, - personalizedQuickReplies(member.display_name), + `${member.display_name}님, 이름 등록과 2학년 4반 가입이 완료되었습니다.\n이제 ‘오늘 일정’처럼 이름 없이 물어볼 수 있어요. 알림 설정에서 수신 여부도 바꿀 수 있습니다.${friendGuide}`, + registeredQuickReplies(), ); } + 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("자료를 조회할 권한이 없습니다. 먼저 관리자에게 받은 코드로 이름등록을 완료해 주세요."); + } + const registered = (await store.listMembers()).filter((member) => member.status !== "left"); + const trailingMatches = registered.filter((member) => { + const name = normalizedText(member.display_name); + return name && (text === name || text.endsWith(` ${name}`)); + }); + if (trailingMatches.some((member) => member.id !== requester.id)) { + return simpleTextResponse("자료를 조회할 권한이 없습니다. 자료는 가입된 본인만 조회할 수 있습니다."); + } + const explicitSelf = trailingMatches.some((member) => member.id === requester.id); + targetDisplayName = requesterName; + targetQuickReplies = explicitSelf ? personalizedQuickReplies(requesterName) : registeredQuickReplies(); + const query = explicitSelf ? text.slice(0, -requesterName.length).trim() : text; + return await answerFileQuery({ command: query, requester, store, makeFileUrl, quickReplies: targetQuickReplies }); + } + const intent = readIntent(command); if (intent) { - const target = resolveTargetMember(command, await store.listMembers()); + const requester = await getRequester(); + const defaultMember = requester?.status === "active" ? requester : null; + const target = resolveTargetMember(command, await store.listMembers(), { defaultMember }); targetDisplayName = target.member.display_name; const targetIntent = readIntent(target.query); if (!targetIntent) throw new Error("조회할 일정 종류가 필요합니다. 예: '오늘 일정 홍길동'"); - return await answerReadQuery({ command: target.query, intent: targetIntent, member: target.member, store, now }); + targetQuickReplies = target.implicit ? registeredQuickReplies() : personalizedQuickReplies(target.member.display_name); + return await answerReadQuery({ + command: target.query, + intent: targetIntent, + member: target.member, + store, + now, + quickReplies: targetQuickReplies, + privateAccess: Boolean(requester && requester.id === target.member.id), + }); } - const member = user ? await store.findMemberByUserKey(user.id) : null; + const member = await getRequester(); const requiresMembership = normalized.includes("알림") || normalized.includes("공지"); if (requiresMembership && !member) { return simpleTextResponse("2학년 4반 구성원만 조회할 수 있습니다. 먼저 ‘가입 초대코드’를 입력해 주세요."); } - const memberReplies = member ? personalizedQuickReplies(member.display_name) : undefined; + const memberReplies = member ? registeredQuickReplies() : undefined; if (normalized.includes("알림")) { if (normalized.includes("아침알림끄기")) { await store.updateMember(member.id, { daily_digest_enabled: false }, member.id); @@ -235,7 +453,7 @@ export async function handleKakaoCommand({ payload, store, now = new Date() }) { const friendly = /초대 코드|이미 다른 구성원|이미 가입|학급 정원|찾을 수 없습니다|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤/.test(error.message) ? error.message : "잠시 후 다시 시도해 주세요."; - const replies = targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined; + const replies = targetQuickReplies || (targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined); return simpleTextResponse(`요청을 처리하지 못했습니다. ${friendly}`, replies); } } diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index 0d954a4..22b1b44 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -14,11 +14,12 @@ function fixture() { return store; } -async function ask(store, utterance) { +async function ask(store, utterance, options = {}) { return handleKakaoCommand({ store, now, - payload: { userRequest: { utterance, user: { id: "group-chat-anonymous" } } }, + makeFileUrl: options.makeFileUrl, + payload: { userRequest: { utterance, user: { id: options.userId || "group-chat-anonymous" } } }, }); } @@ -26,7 +27,7 @@ function text(response) { return response.template.outputs[0].simpleText.text; } -test("익명 그룹챗 개인 조회는 반 전체와 대상 개인 일정만 반환하고 개인화 Quick Reply를 붙인다", async () => { +test("익명 그룹챗 이름 조회는 반 전체 일정만 반환하고 개인 일정은 숨긴다", async () => { const store = fixture(); await store.createEvent({ title: "반 전체 준비", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, title: "홍길동 개인 준비", due_at: "2026-07-15T17:00:00" }); @@ -35,32 +36,287 @@ test("익명 그룹챗 개인 조회는 반 전체와 대상 개인 일정만 const response = await ask(store, "오늘 일정 홍길동"); assert.match(text(response), /홍길동님의 7월 15일 수요일 일정/); assert.match(text(response), /반 전체 준비/); - assert.match(text(response), /홍길동 개인 준비/); - assert.doesNotMatch(text(response), /김학생 개인 준비/); + assert.doesNotMatch(text(response), /홍길동 개인 준비|김학생 개인 준비/); const replies = response.template.quickReplies; assert.deepEqual(replies.map((item) => item.messageText), [ "오늘 일정 홍길동", - "내일 일정 홍길동", - "이번 주 일정 홍길동", - "오늘 시간표 홍길동", - "과제 홍길동", + "다음 일정 홍길동", + "수행평가 과제 통합 요약 홍길동", + "시간표 전체 홍길동", + "자료 목록 홍길동", ]); assert.equal(replies.every((item) => item.label.endsWith("홍길동")), true); }); -test("오늘·내일·모레·이번 주·다음 주와 시험·숙제 변형을 대상 범위로 해석한다", async () => { +function fileFixture() { + const store = fixture(); + store.members[0].status = "active"; + store.members[0].kakao_user_key = "joined-hong"; + store.members[0].kakao_user_key_type = "botUserKey"; + store.members[1].status = "active"; + store.members[1].kakao_user_key = "joined-kim"; + store.members[1].kakao_user_key_type = "botUserKey"; + store.files = [ + { id: "class-image", alias: "좌석표", filename: "seats.png", description: "반 좌석 배치", mime_type: "image/png", size_bytes: 2048, member_id: null, status: "active" }, + { id: "class-pdf", alias: "가정통신문", filename: "notice.pdf", description: "7월 안내", mime_type: "application/pdf", size_bytes: 4096, member_id: null, status: "active" }, + { id: "hong-image", alias: "개인피드백", filename: "feedback.jpg", description: "개인 평가", mime_type: "image/jpeg", size_bytes: 8192, member_id: store.members[0].id, status: "active" }, + { id: "kim-pdf", alias: "김학생자료", filename: "kim.pdf", description: "타인 개인 자료", mime_type: "application/pdf", size_bytes: 1024, member_id: store.members[1].id, status: "active" }, + { id: "archived-pdf", alias: "지난자료", filename: "old.pdf", description: "보관됨", mime_type: "application/pdf", size_bytes: 1024, member_id: null, status: "archived" }, + ]; + store.listFiles = async ({ targetMemberId }) => store.files.filter((file) => file.member_id == null || file.member_id === targetMemberId); + return store; +} + +test("파일 목록은 가입된 본인이 자기 이름을 붙였을 때만 반 전체와 본인 개인자료를 보여준다", async () => { + const store = fileFixture(); + const response = await ask(store, "자료 목록 홍길동", { userId: "joined-hong" }); + assert.match(text(response), /좌석표/); + assert.match(text(response), /가정통신문/); + assert.match(text(response), /개인피드백/); + assert.doesNotMatch(text(response), /김학생자료|지난자료/); + assert.equal(response.template.quickReplies.length, 5); + assert.equal(response.template.quickReplies.at(-1).messageText, "자료 목록 홍길동"); +}); + +test("가입된 본인은 파일 명령에서 이름 suffix를 생략하고 이름 없는 Quick Reply를 받는다", async () => { + const store = fileFixture(); + const makeFileUrl = async (file) => `https://files.example.test/${file.id}`; + const list = await ask(store, "자료 목록", { userId: "joined-hong" }); + assert.match(text(list), /좌석표|개인피드백/); + assert.deepEqual(list.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + + const privateImage = await ask(store, "이미지 개인피드백", { userId: "joined-hong", makeFileUrl }); + assert.equal(privateImage.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/hong-image"); + + const denied = await ask(store, "자료 목록 김학생", { userId: "joined-hong" }); + assert.match(text(denied), /권한이 없습니다/); + assert.doesNotMatch(text(denied), /김학생자료/); +}); + +test("미가입 사용자와 다른 이름을 쓴 가입자는 파일 존재 여부를 알 수 없다", async () => { + const store = fileFixture(); + let calls = 0; + const original = store.listFiles; + store.listFiles = async (options) => { calls += 1; return original(options); }; + + const anonymous = await ask(store, "파일 개인피드백 홍길동", { userId: "unknown-user" }); + const wrongTarget = await ask(store, "파일 개인피드백 홍길동", { userId: "joined-kim" }); + assert.match(text(anonymous), /권한이 없습니다/); + assert.match(text(wrongTarget), /권한이 없습니다/); + assert.doesNotMatch(text(wrongTarget), /개인피드백|찾을 수 없습니다/); + assert.equal(calls, 0); +}); + +test("반 전체 이미지는 인라인 이미지, 개인 이미지와 PDF는 열기 버튼 카드로 반환한다", async () => { + const store = fileFixture(); + const makeFileUrl = async (file) => `https://files.example.test/${file.id}`; + + const classImage = await ask(store, "이미지 좌석표 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(classImage.template.outputs.length, 1); + assert.equal(classImage.template.outputs[0].simpleImage.imageUrl, "https://files.example.test/class-image"); + + const privateImage = await ask(store, "이미지 개인피드백 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(privateImage.template.outputs[0].simpleImage, undefined); + assert.equal(privateImage.template.outputs[0].textCard.buttons[0].label, "파일 열기"); + + const pdf = await ask(store, "PDF 가정통신문 홍길동", { userId: "joined-hong", makeFileUrl }); + assert.equal(pdf.template.outputs[0].textCard.buttons[0].label, "PDF 열기"); + assert.equal(pdf.template.outputs[0].textCard.buttons[0].webLinkUrl, "https://files.example.test/class-pdf"); +}); + +test("파일 형식·별칭이 다르거나 URL 발급기가 없으면 안전한 안내만 반환한다", async () => { + const store = fileFixture(); + const wrongType = await ask(store, "PDF 좌석표 홍길동", { userId: "joined-hong", makeFileUrl: async () => "https://files.example.test/x" }); + assert.match(text(wrongType), /찾을 수 없습니다/); + + const unavailable = await ask(store, "파일 가정통신문 홍길동", { userId: "joined-hong" }); + assert.match(text(unavailable), /자료 열기 기능을 사용할 수 없습니다/); +}); + +test("다음 일정은 현재 이후 가장 빠른 반 전체 또는 개인 일정 한 건만 보여준다", async () => { + const store = fileFixture(); + await store.createEvent({ title: "이미 지난 반 일정", due_at: "2026-07-15T10:00:00+09:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "다른 학생 다음 일정", due_at: "2026-07-15T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "가장 가까운 개인 일정", due_at: "2026-07-15T13:30:00+09:00" }); + await store.createEvent({ title: "그다음 반 일정", due_at: "2026-07-15T14:00:00+09:00" }); + + const response = await ask(store, "다음 일정", { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의 다음 일정/); + assert.match(text(response), /가장 가까운 개인 일정/); + assert.doesNotMatch(text(response), /이미 지난 반 일정|다른 학생 다음 일정|그다음 반 일정/); +}); + +test("이번 달 일정은 서울 달력 경계 안의 반 전체와 본인 일정만 보여준다", async () => { + const store = fileFixture(); + await store.createEvent({ title: "7월 반 일정", due_at: "2026-07-01T00:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "7월 개인 일정", due_at: "2026-07-31T23:59:00+09:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "7월 타인 일정", due_at: "2026-07-20T12:00:00+09:00" }); + await store.createEvent({ title: "8월 일정", due_at: "2026-08-01T00:00:00+09:00" }); + + const response = await ask(store, "이번 달 일정", { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의 2026년 7월 일정/); + assert.match(text(response), /7월 반 일정/); + assert.match(text(response), /7월 개인 일정/); + assert.doesNotMatch(text(response), /7월 타인 일정|8월 일정/); +}); + +test("수행평가·과제 통합 요약은 두 유형만 함께 보여준다", async () => { + const store = fileFixture(); + await store.createEvent({ category: "assessment", title: "물리 수행평가", due_at: "2026-07-16T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "영어 과제", due_at: "2026-07-17T13:00:00+09:00" }); + await store.createEvent({ category: "class", title: "진로 수업", due_at: "2026-07-18T13:00:00+09:00" }); + + const response = await ask(store, "수행평가 과제 통합 요약", { userId: "joined-hong" }); + assert.match(text(response), /수행평가·과제 통합 요약/); + assert.match(text(response), /물리 수행평가/); + assert.match(text(response), /영어 과제/); + assert.doesNotMatch(text(response), /진로 수업/); +}); + +test("시간표 전체는 대상 학생의 개인 시간표를 우선해서 보여준다", async () => { + const store = fileFixture(); + await store.replaceMemberTimetable({ memberId: store.members[0].id, rows: [{ + weekday: 1, + period: 1, + subject: "개인 선택 과목", + teacher: "담당 교사", + room: "선택 강의실", + effective_from: "2026-07-01", + }] }); + const response = await ask(store, "시간표 전체", { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의 월~금 전체 시간표/); + assert.match(text(response), /7월 13일 월요일/); + assert.match(text(response), /7월 17일 금요일/); + assert.match(text(response), /1교시 개인 선택 과목/); +}); + +test("이번 주 남은 일정은 현재 시각 이후 일정만 유지한다", async () => { + const store = fileFixture(); + await store.createEvent({ title: "오늘 지난 일정", due_at: "2026-07-15T10:00:00+09:00" }); + await store.createEvent({ title: "오늘 남은 일정", due_at: "2026-07-15T13:00:00+09:00" }); + await store.createEvent({ member_id: store.members[0].id, title: "금요일 개인 일정", due_at: "2026-07-17T13:00:00+09:00" }); + await store.createEvent({ title: "다음 주 일정", due_at: "2026-07-20T13:00:00+09:00" }); + + const response = await ask(store, "이번 주 남은 일정", { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의 이번 주 남은 일정/); + assert.match(text(response), /오늘 남은 일정/); + assert.match(text(response), /금요일 개인 일정/); + assert.doesNotMatch(text(response), /오늘 지난 일정|다음 주 일정/); +}); + +test("도움말은 최초 이름등록, 이름 없는 본인 조회와 명시적 타인 일정 조회를 안내한다", 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), /다른 구성원의 일정.*등록 이름/); + + 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 () => { 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"); + assert.equal(registration.template.quickReplies[0].messageText, "오늘 일정"); + + const response = await ask(store, "오늘 일정", { userId: "new-hong-user" }); + assert.match(text(response), /홍길동님의.*오늘|홍길동님의 7월 15일/); + assert.match(text(response), /반 전체 일정/); + assert.match(text(response), /홍길동 개인 일정/); + assert.equal(response.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); +}); + +test("active 요청자는 본인 개인 조회를 이름 없이 쓰고 다른 이름 조회에서는 반 일정만 본다", async () => { + const store = fileFixture(); + await store.createEvent({ title: "오늘 반 일정", due_at: "2026-07-15T16:00:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "홍길동 수행평가", due_at: "2026-07-16T16:00:00" }); + await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "홍길동 과제", due_at: "2026-07-17T16:00:00" }); + await store.createEvent({ member_id: store.members[1].id, title: "김학생 개인 일정", due_at: "2026-07-15T18:00:00" }); + + for (const utterance of [ + "오늘 일정", + "내일 일정", + "다음 일정", + "이번 달 일정", + "이번 주 남은 일정", + "수행평가 과제 통합 요약", + "오늘 시간표", + "시간표 전체", + ]) { + const response = await ask(store, utterance, { userId: "joined-hong" }); + assert.match(text(response), /홍길동님의/); + assert.deepEqual(response.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + } + + const other = await ask(store, "오늘 일정 김학생", { userId: "joined-hong" }); + assert.match(text(other), /김학생님의/); + assert.match(text(other), /오늘 반 일정/); + assert.doesNotMatch(text(other), /김학생 개인 일정/); + assert.equal(other.template.quickReplies[0].messageText, "오늘 일정 김학생"); +}); + +test("등록된 요청자의 알림·공지 응답 Quick Reply에도 이름 suffix를 다시 붙이지 않는다", async () => { + const store = fileFixture(); + const notifications = await ask(store, "알림 설정", { userId: "joined-hong" }); + assert.deepEqual(notifications.template.quickReplies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); + + const notices = await ask(store, "공지", { userId: "joined-hong" }); + assert.equal(notices.template.quickReplies.every((item) => !item.messageText.includes("홍길동")), true); +}); + +test("오늘·내일·모레·이번 주·다음 주와 시험·숙제 변형을 대상 범위로 해석한다", async () => { + const store = fileFixture(); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "오늘 시험", due_at: "2026-07-15T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assignment", title: "내일 숙제", due_at: "2026-07-16T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "class", title: "모레 상담", due_at: "2026-07-17T16:00:00" }); await store.createEvent({ member_id: store.members[0].id, category: "assessment", title: "다음 주 시험", due_at: "2026-07-21T16:00:00" }); - assert.match(text(await ask(store, "오늘 시험 홍길동")), /오늘 시험/); - assert.match(text(await ask(store, "내일 숙제 홍길동")), /내일 숙제/); - assert.match(text(await ask(store, "모레 뭐 있어 홍길동?")), /모레 상담/); - assert.match(text(await ask(store, "이번주 일정 홍길동")), /오늘 시험/); - assert.match(text(await ask(store, "다음 주 시험 홍길동")), /다음 주 시험/); - assert.match(text(await ask(store, "과제 홍길동")), /내일 숙제/); + assert.match(text(await ask(store, "오늘 시험 홍길동", { userId: "joined-hong" })), /오늘 시험/); + assert.match(text(await ask(store, "내일 숙제 홍길동", { userId: "joined-hong" })), /내일 숙제/); + assert.match(text(await ask(store, "모레 뭐 있어 홍길동?", { userId: "joined-hong" })), /모레 상담/); + assert.match(text(await ask(store, "이번주 일정 홍길동", { userId: "joined-hong" })), /오늘 시험/); + assert.match(text(await ask(store, "다음 주 시험 홍길동", { userId: "joined-hong" })), /다음 주 시험/); + assert.match(text(await ask(store, "과제 홍길동", { userId: "joined-hong" })), /내일 숙제/); }); test("시간표도 이름을 맨 뒤에 요구하며 요청자 가입 없이 일간·주간 조회한다", async () => { diff --git a/apps/classbot/server/services/file-tokens.js b/apps/classbot/server/services/file-tokens.js new file mode 100644 index 0000000..8cac1b8 --- /dev/null +++ b/apps/classbot/server/services/file-tokens.js @@ -0,0 +1,41 @@ +import crypto from "node:crypto"; + +const DEFAULT_TTL_SECONDS = 15 * 60; + +function signature(payload, secret) { + return crypto.createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function createFileToken(fileId, secret, options = {}) { + const id = String(fileId || "").trim(); + if (!id) throw new Error("파일 식별값이 필요합니다."); + const ttlSeconds = Number(options.ttlSeconds ?? DEFAULT_TTL_SECONDS); + if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 3600) { + throw new Error("파일 링크 유효시간이 올바르지 않습니다."); + } + const now = options.now instanceof Date ? options.now : new Date(); + const payload = Buffer.from(JSON.stringify({ id, exp: Math.floor(now.getTime() / 1000) + ttlSeconds })).toString("base64url"); + return `${payload}.${signature(payload, secret)}`; +} + +export function verifyFileToken(token, secret, options = {}) { + const [payload, supplied, extra] = String(token || "").split("."); + if (!payload || !supplied || extra) throw new Error("올바르지 않은 파일 링크입니다."); + const expected = signature(payload, secret); + const suppliedBuffer = Buffer.from(supplied); + const expectedBuffer = Buffer.from(expected); + if (suppliedBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(suppliedBuffer, expectedBuffer)) { + throw new Error("올바르지 않은 파일 링크입니다."); + } + let decoded; + try { + decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw new Error("올바르지 않은 파일 링크입니다."); + } + const now = options.now instanceof Date ? options.now : new Date(); + if (!decoded?.id || !Number.isInteger(decoded.exp) || decoded.exp <= Math.floor(now.getTime() / 1000)) { + throw new Error("파일 링크가 만료되었습니다. 챗봇에서 다시 요청해 주세요."); + } + return { fileId: String(decoded.id), expiresAt: new Date(decoded.exp * 1000) }; +} diff --git a/apps/classbot/server/services/file-tokens.test.js b/apps/classbot/server/services/file-tokens.test.js new file mode 100644 index 0000000..30ad9e7 --- /dev/null +++ b/apps/classbot/server/services/file-tokens.test.js @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createFileToken, verifyFileToken } from "./file-tokens.js"; + +const secret = "test-secret-long-enough-for-file-token"; +const now = new Date("2026-07-16T00:00:00.000Z"); + +test("파일 토큰은 파일 ID를 숨기고 만료 전 검증된다", () => { + const token = createFileToken("private-file-id", secret, { now, ttlSeconds: 60 }); + assert.equal(token.includes("private-file-id"), false); + const result = verifyFileToken(token, secret, { now: new Date(now.getTime() + 59_000) }); + assert.equal(result.fileId, "private-file-id"); +}); + +test("변조되거나 만료된 파일 토큰은 거부한다", () => { + const token = createFileToken("file-id", secret, { now, ttlSeconds: 60 }); + assert.throws(() => verifyFileToken(`${token}x`, secret, { now }), /올바르지 않은/); + assert.throws(() => verifyFileToken(token, secret, { now: new Date(now.getTime() + 61_000) }), /만료/); +}); diff --git a/apps/classbot/server/services/kakao.js b/apps/classbot/server/services/kakao.js index 2c6f002..f6a88f2 100644 --- a/apps/classbot/server/services/kakao.js +++ b/apps/classbot/server/services/kakao.js @@ -31,25 +31,71 @@ function normalizedEventData(data) { return Object.fromEntries(entries); } -export function simpleTextResponse(text, quickReplies = QUICK_REPLIES) { +export function templateResponse(outputs, quickReplies = QUICK_REPLIES) { + if (!Array.isArray(outputs) || !outputs.length) throw new Error("Kakao 응답에는 출력이 하나 이상 필요합니다."); return { version: "2.0", template: { - outputs: [{ simpleText: { text: String(text).slice(0, 1000) } }], + outputs: outputs.slice(0, 3), quickReplies, }, }; } +export function simpleTextResponse(text, quickReplies = QUICK_REPLIES) { + return templateResponse([{ simpleText: { text: String(text).slice(0, 1000) } }], quickReplies); +} + +function safeWebUrl(value) { + const url = new URL(String(value || "")); + if (!new Set(["https:", "http:"]).has(url.protocol) || url.username || url.password) { + throw new Error("자료 URL은 인증정보가 없는 HTTP 또는 HTTPS 주소여야 합니다."); + } + return url.href; +} + +export function simpleImageResponse({ imageUrl, altText }, quickReplies = QUICK_REPLIES) { + return templateResponse([{ + simpleImage: { + imageUrl: safeWebUrl(imageUrl), + altText: String(altText || "Quilo 자료 이미지").slice(0, 1000), + }, + }], quickReplies); +} + +export function textCardResponse({ title, description, url, buttonLabel = "열기" }, quickReplies = QUICK_REPLIES) { + return templateResponse([{ + textCard: { + title: String(title || "Quilo 자료").slice(0, 50), + description: String(description || "").slice(0, 230), + buttons: [{ + action: "webLink", + label: String(buttonLabel || "열기").slice(0, 14), + webLinkUrl: safeWebUrl(url), + }], + }, + }], quickReplies); +} + export function personalizedQuickReplies(displayName) { const name = String(displayName || "").trim(); if (!name) return QUICK_REPLIES; return [ `오늘 일정 ${name}`, - `내일 일정 ${name}`, - `이번 주 일정 ${name}`, - `오늘 시간표 ${name}`, - `과제 ${name}`, + `다음 일정 ${name}`, + `수행평가 과제 통합 요약 ${name}`, + `시간표 전체 ${name}`, + `자료 목록 ${name}`, + ].map((messageText) => ({ label: messageText, action: "message", messageText })); +} + +export function registeredQuickReplies() { + return [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", ].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 1664e19..2eea099 100644 --- a/apps/classbot/server/services/kakao.test.js +++ b/apps/classbot/server/services/kakao.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { KakaoEventClient } from "./kakao.js"; +import { KakaoEventClient, personalizedQuickReplies, registeredQuickReplies, templateResponse } from "./kakao.js"; const config = { enabled: true, @@ -51,3 +51,23 @@ test("Event API 사용자 타입, 인원수, data 타입을 발송 전에 검증 /모두 문자열/, ); }); + +test("스킬 응답 output은 최대 3개로 제한하고 개인화 Quick Reply는 자료 목록을 포함해 5개다", () => { + const outputs = Array.from({ length: 5 }, (_, index) => ({ simpleText: { text: String(index) } })); + const response = templateResponse(outputs, personalizedQuickReplies("홍길동")); + assert.equal(response.template.outputs.length, 3); + assert.equal(response.template.quickReplies.length, 5); + assert.equal(response.template.quickReplies.at(-1).messageText, "자료 목록 홍길동"); + assert.equal(response.template.quickReplies.every((item) => item.messageText.endsWith("홍길동")), true); +}); + +test("이름등록이 끝난 요청자용 Quick Reply는 이름 suffix 없이 5개다", () => { + const replies = registeredQuickReplies(); + assert.deepEqual(replies.map((item) => item.messageText), [ + "오늘 일정", + "다음 일정", + "수행평가 과제 통합 요약", + "시간표 전체", + "자료 목록", + ]); +}); diff --git a/apps/classbot/server/services/portal-session.js b/apps/classbot/server/services/portal-session.js new file mode 100644 index 0000000..162a94a --- /dev/null +++ b/apps/classbot/server/services/portal-session.js @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; + +export const PORTAL_COOKIE_NAME = "classbot_portal"; +export const PORTAL_SESSION_SECONDS = 30 * 24 * 60 * 60; + +const TOKEN_VERSION = "p1"; + +function signature(payload, secret) { + return crypto + .createHmac("sha256", String(secret || "")) + .update(`${TOKEN_VERSION}.${payload}`) + .digest("base64url"); +} + +function invalidToken() { + return new Error("올바르지 않은 학생 포털 세션입니다."); +} + +export function createPortalToken(memberId, secret, options = {}) { + const subject = String(memberId || "").trim(); + if (!subject || subject.length > 128) throw new Error("학생 식별값이 필요합니다."); + if (!secret) throw new Error("학생 포털 세션 비밀키가 필요합니다."); + + const ttlSeconds = Number(options.ttlSeconds ?? PORTAL_SESSION_SECONDS); + if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > PORTAL_SESSION_SECONDS) { + throw new Error("학생 포털 세션 유효시간이 올바르지 않습니다."); + } + const now = options.now instanceof Date ? options.now : new Date(); + const issuedAt = Math.floor(now.getTime() / 1000); + const payload = Buffer.from(JSON.stringify({ sub: subject, iat: issuedAt, exp: issuedAt + ttlSeconds })) + .toString("base64url"); + return `${TOKEN_VERSION}.${payload}.${signature(payload, secret)}`; +} + +export function verifyPortalToken(token, secret, options = {}) { + const [version, payload, supplied, extra] = String(token || "").split("."); + if (version !== TOKEN_VERSION || !payload || !supplied || extra || !secret) throw invalidToken(); + + const expected = signature(payload, secret); + const suppliedBuffer = Buffer.from(supplied); + const expectedBuffer = Buffer.from(expected); + if (suppliedBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(suppliedBuffer, expectedBuffer)) { + throw invalidToken(); + } + + let decoded; + try { + decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw invalidToken(); + } + const now = Math.floor((options.now instanceof Date ? options.now : new Date()).getTime() / 1000); + if ( + !decoded?.sub + || String(decoded.sub).length > 128 + || !Number.isInteger(decoded.iat) + || !Number.isInteger(decoded.exp) + || decoded.iat > now + 60 + || decoded.exp <= now + || decoded.exp <= decoded.iat + || decoded.exp - decoded.iat > PORTAL_SESSION_SECONDS + ) { + throw invalidToken(); + } + return { + memberId: String(decoded.sub), + issuedAt: new Date(decoded.iat * 1000), + expiresAt: new Date(decoded.exp * 1000), + }; +} + +export function readPortalCookie(cookieHeader) { + for (const part of String(cookieHeader || "").split(";")) { + const separator = part.indexOf("="); + if (separator < 0 || part.slice(0, separator).trim() !== PORTAL_COOKIE_NAME) continue; + try { + return decodeURIComponent(part.slice(separator + 1).trim()); + } catch { + return ""; + } + } + return ""; +} + +export function portalCookieOptions({ embedded = false, production = false } = {}) { + return { + httpOnly: true, + secure: production, + sameSite: "lax", + path: embedded ? "/schedule" : "/", + maxAge: PORTAL_SESSION_SECONDS * 1000, + }; +} diff --git a/apps/classbot/server/services/portal-session.test.js b/apps/classbot/server/services/portal-session.test.js new file mode 100644 index 0000000..d4862f1 --- /dev/null +++ b/apps/classbot/server/services/portal-session.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createPortalToken, + portalCookieOptions, + PORTAL_COOKIE_NAME, + readPortalCookie, + verifyPortalToken, +} from "./portal-session.js"; + +const secret = "portal-test-secret-that-is-long-enough"; +const now = new Date("2026-07-15T03:00:00.000Z"); + +test("학생 포털 토큰은 구성원과 30일 만료를 HMAC으로 보호한다", () => { + const token = createPortalToken("member-1", secret, { now }); + const verified = verifyPortalToken(token, secret, { now: new Date(now.getTime() + 29 * 86_400_000) }); + assert.equal(verified.memberId, "member-1"); + assert.equal(verified.expiresAt.toISOString(), "2026-08-14T03:00:00.000Z"); + assert.throws(() => verifyPortalToken(`${token}x`, secret, { now }), /올바르지 않은/); + assert.throws( + () => verifyPortalToken(token, secret, { now: new Date(now.getTime() + 30 * 86_400_000) }), + /올바르지 않은/, + ); +}); + +test("학생 포털 쿠키 helper는 mount 경로와 보안 속성을 고정한다", () => { + assert.equal(PORTAL_COOKIE_NAME, "classbot_portal"); + assert.deepEqual(portalCookieOptions({ embedded: true, production: true }), { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/schedule", + maxAge: 30 * 86_400_000, + }); + assert.equal(portalCookieOptions().path, "/"); + assert.equal(readPortalCookie("other=x; classbot_portal=abc.def; another=y"), "abc.def"); + assert.equal(readPortalCookie("other=x"), ""); +}); diff --git a/apps/classbot/server/services/schedule.js b/apps/classbot/server/services/schedule.js index 129a8bc..44000d3 100644 --- a/apps/classbot/server/services/schedule.js +++ b/apps/classbot/server/services/schedule.js @@ -19,9 +19,19 @@ export function categoryLabel(category) { return CATEGORY_LABELS[category] || "일정"; } +async function timetableForMemberOrClass(store, { targetMemberId, weekday, date }) { + if (targetMemberId && typeof store.listMemberTimetable === "function") { + const personal = await store.listMemberTimetable(targetMemberId, { weekday, date }); + if (personal.length) return personal; + } + return store.listTimetable({ weekday }); +} + export async function getDaySchedule(store, date = new Date(), { targetMemberId } = {}) { const parts = getSeoulParts(date); - const timetable = parts.weekday >= 1 && parts.weekday <= 5 ? await store.listTimetable({ weekday: parts.weekday }) : []; + const timetable = parts.weekday >= 1 && parts.weekday <= 5 + ? await timetableForMemberOrClass(store, { targetMemberId, weekday: parts.weekday, date: parts.dateKey }) + : []; const events = await store.listEvents({ from: startOfSeoulDay(date).toISOString(), to: endOfSeoulDay(date).toISOString(), @@ -58,13 +68,21 @@ export async function getUpcomingEvents(store, date = new Date(), days = 30, { t }); } -export async function getWeekTimetable(store, date = new Date(), { weekOffset = 0 } = {}) { +export async function getWeekTimetable(store, date = new Date(), { weekOffset = 0, targetMemberId } = {}) { const { monday } = getWeekBounds(date, weekOffset); - const rows = await store.listTimetable(); + const [classRows, memberRows] = await Promise.all([ + store.listTimetable(), + targetMemberId && typeof store.listMemberTimetable === "function" + ? store.listMemberTimetable(targetMemberId, { date: getSeoulParts(monday).dateKey }) + : Promise.resolve([]), + ]); const days = Array.from({ length: 5 }, (_, index) => ({ date: dateForSeoulOffset(monday, index), weekday: index + 1, - rows: rows.filter((row) => row.weekday === index + 1), + rows: (() => { + const personal = memberRows.filter((row) => row.weekday === index + 1); + return personal.length ? personal : classRows.filter((row) => row.weekday === index + 1); + })(), })); return { monday, days }; } diff --git a/apps/classbot/server/services/schedule.test.js b/apps/classbot/server/services/schedule.test.js new file mode 100644 index 0000000..159c567 --- /dev/null +++ b/apps/classbot/server/services/schedule.test.js @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MemoryStore } from "../store/memory-store.js"; +import { getDaySchedule, getWeekTimetable } from "./schedule.js"; + +const config = { classCode: "2-4", className: "2학년 4반", timezone: "Asia/Seoul" }; +const wednesday = new Date("2026-07-15T03:00:00.000Z"); + +test("대상 구성원의 개인 시간표를 우선하고 개인 행이 없으면 반 시간표로 폴백한다", async () => { + const store = new MemoryStore(config); + const member = store.members[0]; + const otherMember = store.members[1]; + await store.replaceMemberTimetable({ + memberId: member.id, + rows: [{ weekday: 3, period: 1, subject: "개인 선택 수업", effective_from: "2026-03-01" }], + }); + + const personal = await getDaySchedule(store, wednesday, { targetMemberId: member.id }); + const fallback = await getDaySchedule(store, wednesday, { targetMemberId: otherMember.id }); + + assert.equal(personal.timetable[0].subject, "개인 선택 수업"); + assert.equal(fallback.timetable[0].subject, "물리학"); +}); + +test("주간 개인 시간표는 개인 행이 등록된 요일만 덮어쓰고 나머지는 반 시간표를 유지한다", async () => { + const store = new MemoryStore(config); + const member = store.members[0]; + await store.replaceMemberTimetable({ + memberId: member.id, + rows: [{ weekday: 1, period: 1, subject: "월요일 개인 수업", effective_from: "2026-03-01" }], + }); + + const bundle = await getWeekTimetable(store, wednesday, { targetMemberId: member.id }); + assert.equal(bundle.days[0].rows[0].subject, "월요일 개인 수업"); + assert.equal(bundle.days[1].rows[0].subject, "영어"); +}); + +test("개인 시간표 저장은 구성원별로 격리하고 조회일에 유효한 최신 버전만 선택한다", async () => { + const store = new MemoryStore(config); + const member = store.members[0]; + const otherMember = store.members[1]; + await store.replaceMemberTimetable({ + memberId: member.id, + rows: [ + { weekday: 3, period: 1, subject: "1학기 수업", effective_from: "2026-03-01", effective_to: "2026-07-31" }, + { weekday: 3, period: 1, subject: "2학기 수업", effective_from: "2026-08-01" }, + ], + }); + + assert.equal((await store.listMemberTimetable(member.id, { date: "2026-07-15" }))[0].subject, "1학기 수업"); + assert.equal((await store.listMemberTimetable(member.id, { date: "2026-08-15" }))[0].subject, "2학기 수업"); + assert.deepEqual(await store.listMemberTimetable(otherMember.id, { date: "2026-08-15" }), []); +}); + +test("개인 시간표 교체는 중복 교시와 잘못된 적용 기간을 거부한다", async () => { + const store = new MemoryStore(config); + const memberId = store.members[0].id; + await assert.rejects( + store.replaceMemberTimetable({ + memberId, + rows: [ + { weekday: 1, period: 1, subject: "수학", effective_from: "2026-08-01" }, + { weekday: 1, period: 1, subject: "영어", effective_from: "2026-08-01" }, + ], + }), + /동일 교시/, + ); + await assert.rejects( + store.replaceMemberTimetable({ + memberId, + rows: [{ weekday: 1, period: 1, subject: "수학", effective_from: "2026-09-01", effective_to: "2026-08-01" }], + }), + /종료일/, + ); +}); diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 4d5e7b6..1edf422 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -23,6 +23,73 @@ function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; } +function fileMetadata(file) { + const { body: _body, ...metadata } = file; + return clone(metadata); +} + +function timetableDateKey(value = new Date()) { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); + return getSeoulParts(value).dateKey; + } + const raw = String(value || "").trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + const parsed = new Date(`${raw}T00:00:00+09:00`); + if (!Number.isNaN(parsed.getTime()) && getSeoulParts(parsed).dateKey === raw) return raw; + throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); + } + const parsed = new Date(raw); + if (raw && !Number.isNaN(parsed.getTime())) return getSeoulParts(parsed).dateKey; + throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); +} + +function normalizeMemberTimetableRows(rows) { + if (!Array.isArray(rows)) throw new Error("개인 시간표 데이터는 배열이어야 합니다."); + const seen = new Set(); + return rows + .filter((row) => String(row?.subject || "").trim()) + .map((row) => { + const weekday = Number(row.weekday); + const period = Number(row.period); + if (!Number.isInteger(weekday) || weekday < 1 || weekday > 5) throw new Error("요일은 월요일부터 금요일까지만 선택할 수 있습니다."); + if (!Number.isInteger(period) || period < 1 || period > 12) throw new Error("교시는 1부터 12 사이여야 합니다."); + const effectiveFrom = timetableDateKey(row.effective_from || new Date()); + const effectiveTo = row.effective_to ? timetableDateKey(row.effective_to) : null; + if (effectiveTo && effectiveTo < effectiveFrom) throw new Error("시간표 종료일은 시작일보다 빠를 수 없습니다."); + const normalized = { + weekday, + period, + subject: String(row.subject).trim(), + activity: String(row.activity || "").trim(), + teacher: String(row.teacher || "").trim(), + room: String(row.room || "").trim(), + memo: String(row.memo || "").trim(), + effective_from: effectiveFrom, + effective_to: effectiveTo, + }; + const limits = { subject: 100, activity: 300, teacher: 100, room: 100, memo: 500 }; + for (const [key, limit] of Object.entries(limits)) { + if (normalized[key].length > limit) throw new Error(`시간표 ${key} 값이 너무 깁니다.`); + } + const slot = `${weekday}:${period}:${effectiveFrom}`; + if (seen.has(slot)) throw new Error("같은 적용일의 동일 교시를 두 번 등록할 수 없습니다."); + seen.add(slot); + return normalized; + }); +} + +function activeTimetableRows(rows, dateKey) { + const latestBySlot = new Map(); + for (const row of rows + .filter((item) => item.effective_from <= dateKey && (!item.effective_to || item.effective_to >= dateKey)) + .sort((a, b) => b.effective_from.localeCompare(a.effective_from))) { + const slot = `${row.weekday}:${row.period}`; + if (!latestBySlot.has(slot)) latestBySlot.set(slot, row); + } + return [...latestBySlot.values()].sort((a, b) => a.weekday - b.weekday || a.period - b.period); +} + function seedTimetable(classId) { const subjects = { 1: [ @@ -176,6 +243,7 @@ export class MemoryStore { })); this.invites = []; this.timetable = seedTimetable(classId); + this.memberTimetable = []; this.events = seedEvents(classId); this.notices = [ { @@ -195,6 +263,8 @@ export class MemoryStore { ]; this.notifications = []; this.auditLogs = []; + this.files = []; + this.fileBodies = new Map(); } async healthCheck() { @@ -291,7 +361,10 @@ export class MemoryStore { if (member.status === "active") throw new Error("이미 가입한 구성원에게 초대 코드를 만들 수 없습니다."); const now = nowIso(); for (const previous of this.invites) { - if (previous.member_id === memberId && !previous.used_at) previous.used_at = now; + if (previous.member_id === memberId && (!previous.used_at || !previous.portal_used_at)) { + previous.used_at = previous.used_at || now; + previous.portal_used_at = previous.portal_used_at || now; + } } const invite = { id: id("invite"), @@ -300,6 +373,7 @@ export class MemoryStore { code_hash: codeHash, expires_at: expiresAt, used_at: null, + portal_used_at: null, created_at: nowIso(), }; this.invites.push(invite); @@ -325,6 +399,27 @@ export class MemoryStore { return clone(member); } + async claimPortalInvite({ memberId, code }) { + const member = this.members.find((item) => item.id === memberId); + const codeHash = hashInviteCode(code); + const invite = this.invites.find((item) => ( + item.member_id === memberId + && item.code_hash === codeHash + && !item.portal_used_at + && new Date(item.expires_at).getTime() > Date.now() + )); + if (!member || !invite) throw new Error("이름 또는 초대 코드를 확인할 수 없습니다."); + invite.portal_used_at = nowIso(); + await this.appendAudit({ + actor: member.id, + action: "portal.login", + entityType: "invite", + entityId: invite.id, + after: { member_id: member.id, portal_used_at: invite.portal_used_at }, + }); + return clone(member); + } + async findMemberByUserKey(userKey) { const member = this.members.find((item) => item.kakao_user_key === userKey && item.status === "active"); return member ? clone(member) : null; @@ -336,6 +431,39 @@ export class MemoryStore { .sort((a, b) => a.weekday - b.weekday || a.period - b.period); } + async listMemberTimetable(memberId, { weekday, date } = {}) { + const member = this.members.find((item) => item.id === memberId && item.class_id === this.classroom.id && item.status !== "left"); + if (!member) throw new Error("개인 시간표를 조회할 구성원을 찾을 수 없습니다."); + const dateKey = timetableDateKey(date || new Date()); + return clone(activeTimetableRows( + this.memberTimetable.filter((row) => row.member_id === memberId && (weekday == null || row.weekday === Number(weekday))), + dateKey, + )); + } + + async replaceMemberTimetable({ memberId, rows }, actor = "admin") { + const member = this.members.find((item) => item.id === memberId && item.class_id === this.classroom.id && item.status !== "left"); + if (!member) throw new Error("개인 시간표를 등록할 구성원을 찾을 수 없습니다."); + const savedAt = nowIso(); + const normalized = normalizeMemberTimetableRows(rows).map((row) => ({ + id: id("member_tt"), + class_id: this.classroom.id, + member_id: memberId, + ...row, + created_at: savedAt, + updated_at: savedAt, + })); + this.memberTimetable = this.memberTimetable.filter((row) => row.member_id !== memberId).concat(normalized); + await this.appendAudit({ + actor, + action: "member_timetable.replace", + entityType: "member_timetable", + entityId: memberId, + after: { member_id: memberId, row_count: normalized.length }, + }); + return clone(normalized).sort((a, b) => a.weekday - b.weekday || a.period - b.period); + } + async replaceTimetableDay({ weekday, rows }, actor = "admin") { const numericWeekday = Number(weekday); if (!Number.isInteger(numericWeekday) || numericWeekday < 1 || numericWeekday > 5) throw new Error("요일은 월요일부터 금요일까지만 선택할 수 있습니다."); @@ -502,6 +630,88 @@ export class MemoryStore { return { notice: await this.updateNotice(noticeId, { status: "published" }, actor), newlyPublished: true }; } + async listFiles({ targetMemberId, all = false, status = "active" } = {}) { + return this.files + .filter((file) => (!status || file.status === status)) + .filter((file) => all || file.member_id == null || (targetMemberId && file.member_id === targetMemberId)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .map(fileMetadata); + } + + async getFile(fileId) { + const file = this.files.find((item) => item.id === fileId); + return file ? fileMetadata(file) : null; + } + + async createFile(input, body, actor = "admin") { + if (!Buffer.isBuffer(body) || body.length < 1 || body.length > 20 * 1024 * 1024) { + throw new Error("파일은 20MB 이하여야 합니다."); + } + if (this.files.filter((file) => file.status === "active").length >= 100) { + throw new Error("자료실에는 최대 100개의 파일만 보관할 수 있습니다."); + } + const storedBytes = [...this.fileBodies.values()].reduce((sum, value) => sum + value.length, 0); + if (storedBytes + body.length > 100 * 1024 * 1024) { + throw new Error("개발용 자료실의 총 저장 용량 100MB를 초과할 수 없습니다."); + } + const alias = String(input.alias || "").trim(); + const duplicate = this.files.find((file) => ( + file.status === "active" + && file.member_id === (input.member_id || null) + && file.alias.toLocaleLowerCase("ko") === alias.toLocaleLowerCase("ko") + )); + if (duplicate) throw new Error("같은 대상에 동일한 자료 별칭을 두 번 등록할 수 없습니다."); + const now = nowIso(); + const file = { + id: id("file"), + class_id: this.classroom.id, + member_id: input.member_id || null, + alias, + filename: String(input.filename || "").trim(), + description: String(input.description || "").trim(), + mime_type: input.mime_type, + size_bytes: body.length, + bucket: "memory", + object_path: id("object"), + status: "active", + created_by: actor, + created_at: now, + updated_at: now, + }; + this.files.push(file); + this.fileBodies.set(file.id, Buffer.from(body)); + await this.appendAudit({ actor, action: "file.create", entityType: "file", entityId: file.id, after: file }); + return fileMetadata(file); + } + + async updateFile(fileId, patch, actor = "admin") { + const file = this.files.find((item) => item.id === fileId); + if (!file) throw new Error("파일을 찾을 수 없습니다."); + const before = fileMetadata(file); + for (const key of ["alias", "description", "member_id", "status"]) { + if (patch[key] !== undefined) file[key] = patch[key]; + } + file.updated_at = nowIso(); + await this.appendAudit({ actor, action: "file.update", entityType: "file", entityId: file.id, before, after: file }); + return fileMetadata(file); + } + + async deleteFile(fileId, actor = "admin") { + const index = this.files.findIndex((item) => item.id === fileId); + if (index < 0) throw new Error("파일을 찾을 수 없습니다."); + const [file] = this.files.splice(index, 1); + this.fileBodies.delete(file.id); + await this.appendAudit({ actor, action: "file.delete", entityType: "file", entityId: file.id, before: file }); + return fileMetadata(file); + } + + async downloadFile(fileId) { + const file = this.files.find((item) => item.id === fileId && item.status === "active"); + const body = this.fileBodies.get(fileId); + if (!file || !body) throw new Error("파일을 찾을 수 없습니다."); + return Buffer.from(body); + } + async listNotifications({ limit = 50, status } = {}) { return clone(this.notifications) .filter((item) => !status || item.status === status) diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index 2eaa644..e0b53b6 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,13 +34,42 @@ 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/); - for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "events", "notices", "notifications", "audit_logs"]) { + 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, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } assert.match(storeSource, /\.rpc\("classbot_health_check"\)/); assert.match(storeSource, /result\.error\.code === "23505"/); }); +test("개인별 시간표는 학급-구성원 복합 경계와 원자적 전체 교체 RPC를 사용한다", () => { + assert.match(schema, /create table if not exists public\.classbot_member_timetable/); + assert.match(schema, /foreign key \(class_id, member_id\)[\s\S]*references public\.classbot_members\(class_id, id\)/); + assert.match(schema, /unique \(class_id, member_id, weekday, period, effective_from\)/); + assert.match(schema, /classbot_member_timetable_lookup_idx/); + const replaceFunction = schema.match(/create or replace function public\.classbot_replace_member_timetable[\s\S]*?\$\$;/)?.[0] || ""; + 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/); +}); + +test("카카오와 학생 포털은 같은 초대 코드의 일회성 사용 상태를 채널별로 분리한다", () => { + assert.match(schema, /portal_used_at timestamptz/); + assert.match(storeSource, /update\(\{ portal_used_at: usedAt \}\)/); + assert.match(storeSource, /\.is\("portal_used_at", null\)/); +}); + +test("자료실은 비공개 서버 경계와 대상별 별칭 중복 방지를 스키마에 둔다", () => { + assert.match(schema, /create table if not exists public\.classbot_files/); + assert.match(schema, /mime_type in \('application\/pdf', 'image\/jpeg', 'image\/png', 'image\/webp', 'image\/gif'\)/); + assert.match(schema, /size_bytes between 1 and 20971520/); + assert.match(schema, /classbot_files_class_alias_idx/); + assert.match(schema, /classbot_files_member_alias_idx/); + assert.match(storeSource, /storage\.createBucket\(FILE_BUCKET/); + assert.match(storeSource, /public: false/); +}); + test("개인 일정은 nullable member FK와 대상별 반 전체 포함 조회를 사용한다", () => { assert.match(schema, /classbot_events[\s\S]*member_id uuid references public\.classbot_members\(id\) on delete cascade/); assert.match(schema, /classbot_events_member_due_idx/); diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index fdfd8ad..285fd39 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -1,6 +1,7 @@ +import crypto from "node:crypto"; import { createClient } from "@supabase/supabase-js"; import { hashInviteCode } from "../security.js"; -import { parseKoreaDateTime } from "../time.js"; +import { getSeoulParts, parseKoreaDateTime } from "../time.js"; function unwrap(result, message) { if (result.error) throw new Error(`${message}: ${result.error.message}`); @@ -27,6 +28,76 @@ function fetchWithTimeout(url, options = {}) { return fetch(url, { ...options, signal }); } +const FILE_BUCKET = "classbot-files"; +const FILE_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"]; +const FILE_EXTENSIONS = new Map([ + ["application/pdf", "pdf"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], + ["image/gif", "gif"], +]); + +function timetableDateKey(value = new Date()) { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); + return getSeoulParts(value).dateKey; + } + const raw = String(value || "").trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + const parsed = new Date(`${raw}T00:00:00+09:00`); + if (!Number.isNaN(parsed.getTime()) && getSeoulParts(parsed).dateKey === raw) return raw; + throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); + } + const parsed = new Date(raw); + if (raw && !Number.isNaN(parsed.getTime())) return getSeoulParts(parsed).dateKey; + throw new Error("올바른 시간표 적용 날짜를 입력해 주세요."); +} + +function normalizeMemberTimetableRows(rows) { + if (!Array.isArray(rows)) throw new Error("개인 시간표 데이터는 배열이어야 합니다."); + const seen = new Set(); + return rows + .filter((row) => String(row?.subject || "").trim()) + .map((row) => { + const weekday = Number(row.weekday); + const period = Number(row.period); + if (!Number.isInteger(weekday) || weekday < 1 || weekday > 5) throw new Error("요일은 월요일부터 금요일까지만 선택할 수 있습니다."); + if (!Number.isInteger(period) || period < 1 || period > 12) throw new Error("교시는 1부터 12 사이여야 합니다."); + const effectiveFrom = timetableDateKey(row.effective_from || new Date()); + const effectiveTo = row.effective_to ? timetableDateKey(row.effective_to) : null; + if (effectiveTo && effectiveTo < effectiveFrom) throw new Error("시간표 종료일은 시작일보다 빠를 수 없습니다."); + const normalized = { + weekday, + period, + subject: String(row.subject).trim(), + activity: String(row.activity || "").trim(), + teacher: String(row.teacher || "").trim(), + room: String(row.room || "").trim(), + memo: String(row.memo || "").trim(), + effective_from: effectiveFrom, + effective_to: effectiveTo, + }; + const limits = { subject: 100, activity: 300, teacher: 100, room: 100, memo: 500 }; + for (const [key, limit] of Object.entries(limits)) { + if (normalized[key].length > limit) throw new Error(`시간표 ${key} 값이 너무 깁니다.`); + } + const slot = `${weekday}:${period}:${effectiveFrom}`; + if (seen.has(slot)) throw new Error("같은 적용일의 동일 교시를 두 번 등록할 수 없습니다."); + seen.add(slot); + return normalized; + }); +} + +function activeTimetableRows(rows) { + const latestBySlot = new Map(); + for (const row of rows.slice().sort((a, b) => b.effective_from.localeCompare(a.effective_from))) { + const slot = `${row.weekday}:${row.period}`; + if (!latestBySlot.has(slot)) latestBySlot.set(slot, row); + } + return [...latestBySlot.values()].sort((a, b) => a.weekday - b.weekday || a.period - b.period); +} + export class SupabaseStore { constructor(config) { this.config = config; @@ -64,7 +135,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 1) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 4) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -169,10 +240,10 @@ export class SupabaseStore { unwrap( await this.client .from("classbot_invites") - .update({ used_at: new Date().toISOString() }) + .update({ used_at: new Date().toISOString(), portal_used_at: new Date().toISOString() }) .eq("class_id", classroom.id) .eq("member_id", memberId) - .is("used_at", null), + .or("used_at.is.null,portal_used_at.is.null"), "기존 초대 코드 정리 실패", ); const invite = unwrap( @@ -205,6 +276,35 @@ export class SupabaseStore { return member; } + async claimPortalInvite({ memberId, code }) { + const classroom = await this.ensureClassroom(); + const usedAt = new Date().toISOString(); + const invite = unwrap( + await this.client + .from("classbot_invites") + .update({ portal_used_at: usedAt }) + .eq("class_id", classroom.id) + .eq("member_id", memberId) + .eq("code_hash", hashInviteCode(code)) + .is("portal_used_at", null) + .gt("expires_at", usedAt) + .select("id,member_id,portal_used_at") + .maybeSingle(), + "학생 포털 초대 코드 확인 실패", + ); + if (!invite) throw new Error("이름 또는 초대 코드를 확인할 수 없습니다."); + const member = await this.getMember(memberId); + if (!member) throw new Error("이름 또는 초대 코드를 확인할 수 없습니다."); + await this.appendAudit({ + actor: member.id, + action: "portal.login", + entityType: "invite", + entityId: invite.id, + after: { member_id: member.id, portal_used_at: invite.portal_used_at }, + }).catch(() => {}); + return member; + } + async findMemberByUserKey(userKey) { const classroom = await this.ensureClassroom(); return unwrap( @@ -226,6 +326,51 @@ export class SupabaseStore { return unwrap(await query.order("weekday").order("period"), "시간표 조회 실패"); } + async listMemberTimetable(memberId, { weekday, date } = {}) { + const classroom = await this.ensureClassroom(); + if (!String(memberId || "").trim()) throw new Error("개인 시간표를 조회할 구성원이 필요합니다."); + const dateKey = timetableDateKey(date || new Date()); + let query = this.client + .from("classbot_member_timetable") + .select("*") + .eq("class_id", classroom.id) + .eq("member_id", memberId) + .lte("effective_from", dateKey) + .or(`effective_to.is.null,effective_to.gte.${dateKey}`); + if (weekday != null) { + const numericWeekday = Number(weekday); + if (!Number.isInteger(numericWeekday) || numericWeekday < 1 || numericWeekday > 5) throw new Error("요일은 월요일부터 금요일까지만 선택할 수 있습니다."); + query = query.eq("weekday", numericWeekday); + } + const rows = unwrap( + await query.order("weekday").order("period").order("effective_from", { ascending: false }), + "개인 시간표 조회 실패", + ); + return activeTimetableRows(rows); + } + + async replaceMemberTimetable({ memberId, rows }, actor = "admin") { + const classroom = await this.ensureClassroom(); + if (!String(memberId || "").trim()) throw new Error("개인 시간표를 등록할 구성원이 필요합니다."); + const payload = normalizeMemberTimetableRows(rows); + const saved = unwrap( + await this.client.rpc("classbot_replace_member_timetable", { + p_class_id: classroom.id, + p_member_id: memberId, + p_rows: payload, + }), + "개인 시간표 저장 실패", + ); + await this.appendAudit({ + actor, + action: "member_timetable.replace", + entityType: "member_timetable", + entityId: memberId, + after: { member_id: memberId, row_count: saved.length }, + }); + return saved; + } + async replaceTimetableDay({ weekday, rows }, actor = "admin") { const classroom = await this.ensureClassroom(); const numericWeekday = Number(weekday); @@ -458,6 +603,128 @@ export class SupabaseStore { return { notice: current, newlyPublished: false }; } + async ensureFileBucket() { + const existing = await this.client.storage.getBucket(FILE_BUCKET); + if (existing.data) return; + const created = await this.client.storage.createBucket(FILE_BUCKET, { + public: false, + fileSizeLimit: 20 * 1024 * 1024, + allowedMimeTypes: FILE_MIME_TYPES, + }); + if (created.error && !/already exists|duplicate/i.test(created.error.message || "")) { + throw new Error(`자료실 저장소 준비 실패: ${created.error.message}`); + } + } + + async listFiles({ targetMemberId, all = false, status = "active" } = {}) { + const classroom = await this.ensureClassroom(); + let query = this.client + .from("classbot_files") + .select("*") + .eq("class_id", classroom.id) + .order("created_at", { ascending: false }); + if (status) query = query.eq("status", status); + if (!all) { + query = targetMemberId + ? query.or(`member_id.is.null,member_id.eq.${targetMemberId}`) + : query.is("member_id", null); + } + return unwrap(await query, "자료실 조회 실패"); + } + + async getFile(fileId) { + const classroom = await this.ensureClassroom(); + return unwrap( + await this.client.from("classbot_files").select("*").eq("class_id", classroom.id).eq("id", fileId).maybeSingle(), + "파일 조회 실패", + ); + } + + async createFile(input, body, actor = "admin") { + const classroom = await this.ensureClassroom(); + if (!Buffer.isBuffer(body) || body.length < 1 || body.length > 20 * 1024 * 1024) { + throw new Error("파일은 20MB 이하여야 합니다."); + } + const countResult = await this.client + .from("classbot_files") + .select("id", { count: "exact", head: true }) + .eq("class_id", classroom.id) + .eq("status", "active"); + if (countResult.error) throw new Error(`자료실 용량 확인 실패: ${countResult.error.message}`); + if ((countResult.count || 0) >= 100) throw new Error("자료실에는 최대 100개의 파일만 보관할 수 있습니다."); + + await this.ensureFileBucket(); + const extension = FILE_EXTENSIONS.get(input.mime_type); + if (!extension) throw new Error("PDF 또는 지원되는 이미지 파일만 올릴 수 있습니다."); + const fileId = crypto.randomUUID(); + const objectPath = `${classroom.id}/${fileId}/${crypto.randomUUID()}.${extension}`; + const upload = await this.client.storage.from(FILE_BUCKET).upload(objectPath, body, { + contentType: input.mime_type, + cacheControl: "0", + upsert: false, + }); + if (upload.error) throw new Error(`파일 업로드 실패: ${upload.error.message}`); + + const result = await this.client.from("classbot_files").insert({ + id: fileId, + class_id: classroom.id, + member_id: input.member_id || null, + alias: input.alias, + filename: input.filename, + description: input.description || "", + mime_type: input.mime_type, + size_bytes: body.length, + bucket: FILE_BUCKET, + object_path: objectPath, + status: "active", + created_by: actor, + }).select("*").single(); + if (result.error) { + await this.client.storage.from(FILE_BUCKET).remove([objectPath]); + if (result.error.code === "23505") throw new Error("같은 대상에 동일한 자료 별칭을 두 번 등록할 수 없습니다."); + throw new Error(`파일 정보 저장 실패: ${result.error.message}`); + } + await this.appendAudit({ actor, action: "file.create", entityType: "file", entityId: result.data.id, after: result.data }); + return result.data; + } + + async updateFile(fileId, patch, actor = "admin") { + const classroom = await this.ensureClassroom(); + const before = await this.getFile(fileId); + if (!before) throw new Error("파일을 찾을 수 없습니다."); + const allowed = Object.fromEntries( + Object.entries(patch).filter(([key]) => ["alias", "description", "member_id", "status"].includes(key)), + ); + const updated = unwrap( + await this.client.from("classbot_files").update(allowed).eq("class_id", classroom.id).eq("id", fileId).select("*").single(), + "파일 정보 저장 실패", + ); + await this.appendAudit({ actor, action: "file.update", entityType: "file", entityId: fileId, before, after: updated }); + return updated; + } + + async deleteFile(fileId, actor = "admin") { + const classroom = await this.ensureClassroom(); + const file = await this.getFile(fileId); + if (!file) throw new Error("파일을 찾을 수 없습니다."); + const removedObject = await this.client.storage.from(file.bucket).remove([file.object_path]); + if (removedObject.error) throw new Error(`파일 삭제 실패: ${removedObject.error.message}`); + unwrap( + await this.client.from("classbot_files").delete().eq("class_id", classroom.id).eq("id", fileId), + "파일 정보 삭제 실패", + ); + await this.appendAudit({ actor, action: "file.delete", entityType: "file", entityId: fileId, before: file }); + return file; + } + + async downloadFile(fileId) { + const file = await this.getFile(fileId); + if (!file || file.status !== "active") throw new Error("파일을 찾을 수 없습니다."); + const downloaded = await this.client.storage.from(file.bucket).download(file.object_path); + if (downloaded.error) throw new Error(`파일 다운로드 실패: ${downloaded.error.message}`); + return Buffer.from(await downloaded.data.arrayBuffer()); + } + async listNotifications({ limit = 50, status } = {}) { const classroom = await this.ensureClassroom(); let query = this.client diff --git a/apps/classbot/server/store/supabase-store.test.js b/apps/classbot/server/store/supabase-store.test.js index ad0e9a3..d4456b2 100644 --- a/apps/classbot/server/store/supabase-store.test.js +++ b/apps/classbot/server/store/supabase-store.test.js @@ -37,3 +37,36 @@ test("DB가 기존 구성원을 감지해 0을 반환하면 명단과 audit를 assert.equal(await store.seedMembersIfEmpty([{ display_name: "홍길동", role: "admin" }]), 0); assert.equal(audits.length, 0); }); + +test("개인 시간표 교체는 학급과 구성원을 함께 scope한 원자적 RPC만 호출한다", async () => { + const saved = [{ id: "row-1", member_id: "member-1", weekday: 1, period: 1, subject: "수학" }]; + const { store, calls, audits } = fixture(saved); + const rows = [{ + weekday: 1, + period: 1, + subject: " 수학 ", + activity: " 함수 ", + effective_from: "2026-08-01", + }]; + + assert.deepEqual(await store.replaceMemberTimetable({ memberId: "member-1", rows }), saved); + assert.deepEqual(calls, [{ + name: "classbot_replace_member_timetable", + args: { + p_class_id: "class-private", + p_member_id: "member-1", + p_rows: [{ + weekday: 1, + period: 1, + subject: "수학", + activity: "함수", + teacher: "", + room: "", + memo: "", + effective_from: "2026-08-01", + effective_to: null, + }], + }, + }]); + assert.deepEqual(audits[0].after, { member_id: "member-1", row_count: 1 }); +}); diff --git a/apps/classbot/src/App.jsx b/apps/classbot/src/App.jsx index 2d33d2f..9acdb3f 100644 --- a/apps/classbot/src/App.jsx +++ b/apps/classbot/src/App.jsx @@ -1,25 +1,31 @@ import { useEffect, useMemo, useState } from "react"; -import { AlertTriangle, CheckCircle2, LockKeyhole, RotateCw } from "lucide-react"; +import { AlertTriangle, CheckCircle2, RotateCw } from "lucide-react"; import { api } from "./api/client.js"; -import { BottomNavigation, Brand, Sidebar, Topbar } from "./components/AppShell.jsx"; +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"; const actions = { today: "일정 추가", events: "일정 추가", notices: "공지 추가" }; -const embeddedInQuilo = !import.meta.env.DEV && import.meta.env.BASE_URL.startsWith("/schedule/"); -function Login({ onLogin, busy, error }) { - const [password, setPassword] = useState(""); - if (embeddedInQuilo) { - return

관리자 로그인

Quilo 관리자 계정으로 로그인한 뒤 일정 관리 화면을 이용해 주세요.

Quilo에서 로그인
; - } - return

관리자 로그인

학급 일정과 카카오톡 알림을 관리하려면 로그인해 주세요.

{ e.preventDefault(); onLogin(password); }}>{error &&

{error}

}
; +async function copyText(value) { + if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(value); + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); } function ConnectionError({ message, onRetry }) { @@ -28,11 +34,13 @@ function ConnectionError({ message, onRetry }) { export default function App() { const [session, setSession] = useState(null); + const [portalSession, setPortalSession] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); 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 [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [toast, setToast] = useState(""); @@ -48,6 +56,10 @@ export default function App() { setSession({ ...nextSession, demo: nextSession.demo || api.mode === "local" }); setOverview(data); if (window.matchMedia("(min-width: 1181px)").matches) setDrawer({ type: "event", item: null, open: true }); + } else { + setOverview(null); + setDrawer({ type: null, item: null, open: false }); + setPortalSession(await api.portalSession()); } } catch (err) { setLoadError(err.message || "관리 서버 요청에 실패했습니다."); @@ -59,13 +71,29 @@ export default function App() { const patchCollection = (collection, item) => setOverview((current) => ({ ...current, [collection]: current[collection].map((existing) => existing.id === item.id ? item : existing) })); const addCollection = (collection, item) => setOverview((current) => ({ ...current, [collection]: [item, ...current[collection]] })); + 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: "" }); + } catch (err) { + setFileLibrary((current) => ({ ...current, loading: false, loaded: false, error: err.message || "자료 목록 요청에 실패했습니다." })); + } + }; - const login = async (password) => { + const loginPortal = async (credentials) => { setBusy(true); setError(""); - try { const result = await api.login(password); setSession(result); setOverview(await api.overview()); } + try { await api.portalLogin(credentials); setPortalSession(await api.portalSession()); } catch (err) { setError(err.message); } finally { setBusy(false); } }; + const logoutPortal = async () => { + if (busy) return; + setBusy(true); setError(""); + try { await api.portalLogout(); setPortalSession({ authenticated: false }); } + catch (err) { setError(err.message || "로그아웃하지 못했습니다."); } + finally { setBusy(false); } + }; const openCreate = () => setDrawer({ type: active === "notices" ? "notice" : "event", item: null, open: true }); const closeDrawer = () => setDrawer((current) => ({ ...current, open: false })); @@ -81,7 +109,10 @@ export default function App() { const deleteEvent = async () => { if (busy || !drawer.item || !window.confirm("이 일정을 삭제할까요?")) return; setBusy(true); try { await api.deleteEvent(drawer.item.id); setOverview((current) => ({ ...current, events: current.events.filter((item) => item.id !== drawer.item.id) })); closeDrawer(); flash("일정을 삭제했습니다."); } finally { setBusy(false); } }; const saveNotice = async (input) => { if (busy) return; setBusy(true); try { const result = drawer.item ? await api.updateNotice(drawer.item.id, input) : await api.createNotice(input); drawer.item ? patchCollection("notices", result.item) : addCollection("notices", result.item); closeDrawer(); flash("공지를 저장했습니다."); } catch (err) { flash(err.message); } finally { setBusy(false); } }; const deleteNotice = async () => { if (busy || !drawer.item || !window.confirm("이 공지를 삭제할까요?")) return; setBusy(true); try { await api.deleteNotice(drawer.item.id); setOverview((current) => ({ ...current, notices: current.notices.filter((item) => item.id !== drawer.item.id) })); closeDrawer(); flash("공지를 삭제했습니다."); } finally { setBusy(false); } }; - const navigate = (id) => { setActive(id); if (drawer.open) closeDrawer(); }; + 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 navigate = (id) => { setActive(id); if (id === "files" && !fileLibrary.loaded && !fileLibrary.loading) loadFiles(); if (drawer.open) closeDrawer(); }; const screen = useMemo(() => { if (!overview) return null; @@ -89,15 +120,19 @@ export default function App() { 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 === "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 }); setOverview(null); }} />; - }, [active, overview, busy, drawer.item]); + 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]); if (loading) return
학급 정보를 불러오는 중
; if (loadError && !overview) return ; if (!session) return ; - if (!session.authenticated) return ; + if (!session.authenticated) { + if (portalSession?.authenticated) return ; + return ; + } if (!overview) return
{error || "데이터를 불러오지 못했습니다."}
; return (
diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js index 7325d9e..f93ad03 100644 --- a/apps/classbot/src/api/client.js +++ b/apps/classbot/src/api/client.js @@ -26,6 +26,7 @@ function readLocal() { } let localState = readLocal(); +if (!Array.isArray(localState.files)) localState.files = []; function persistLocal() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(localState)); } catch { /* private mode */ } @@ -37,11 +38,12 @@ function nextId(prefix) { async function request(path, options = {}) { const { body, headers, root = false, ...rest } = options; + const isFormData = typeof FormData !== "undefined" && body instanceof FormData; const response = await fetch(root ? path : resolveApiPath(path), { credentials: "same-origin", ...rest, - headers: body ? { "Content-Type": "application/json", ...headers } : headers, - body: body && typeof body !== "string" ? JSON.stringify(body) : body, + headers: body && !isFormData ? { "Content-Type": "application/json", ...headers } : headers, + body: body && typeof body !== "string" && !isFormData ? JSON.stringify(body) : body, }); const data = await response.json().catch(() => ({})); if (!response.ok) { @@ -111,6 +113,13 @@ export const api = { createMember(input, options) { return remoteOrLocal(() => request("/api/admin/members", { method: "POST", headers: idempotencyHeaders("member", input, options), body: input }), () => createItem("members", "member", { status: "invited", notification_enabled: true, daily_digest_enabled: true, ...input })); }, updateMember(id, patch) { return remoteOrLocal(() => request(`/api/admin/members/${id}`, { method: "PATCH", body: patch }), () => updateItem("members", id, patch)); }, inviteMember(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/members/${id}/invite`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("invite") } }), () => ({ invite_url: `${location.origin}/join/demo-${id.slice(-4)}`, expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString() })); }, + memberTimetable(id, { weekday, date } = {}) { + const query = new URLSearchParams(); + if (weekday != null) query.set("weekday", String(weekday)); + if (date) query.set("date", date); + return request(`/api/admin/members/${id}/timetable${query.size ? `?${query.toString()}` : ""}`); + }, + saveMemberTimetable(id, rows) { return request(`/api/admin/members/${id}/timetable`, { method: "PUT", body: { rows } }); }, timetable() { return remoteOrLocal(() => request("/api/admin/timetable"), () => ({ items: structuredClone(localState.timetable) })); }, saveTimetable(weekday, items) { return remoteOrLocal(async () => { @@ -131,6 +140,64 @@ export const api = { updateNotice(id, patch) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "PATCH", body: patch }), () => updateItem("notices", id, patch)); }, 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) })); }, + uploadFile(input, options) { + const body = new FormData(); + body.append("file", input.file); + body.append("alias", input.alias); + body.append("description", input.description || ""); + body.append("member_id", input.member_id || ""); + body.append("visibility", input.member_id ? "private" : "class"); + return remoteOrLocal( + () => request("/api/admin/files", { method: "POST", headers: idempotencyHeaders("file", input, options), body }), + () => { + const now = new Date().toISOString(); + const item = { + id: nextId("file"), alias: input.alias, description: input.description || "", member_id: input.member_id || null, + filename: input.file.name, original_name: input.file.name, mime_type: input.file.type || "application/octet-stream", + size_bytes: input.file.size, visibility: input.member_id ? "private" : "class", + download_url: URL.createObjectURL(input.file), created_at: now, updated_at: now, + }; + localState.files.unshift(item); + persistLocal(); + return { item: structuredClone(item) }; + }, + ); + }, + deleteFile(id) { return remoteOrLocal(() => request(`/api/admin/files/${id}`, { method: "DELETE" }), () => { localState.files = localState.files.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); }, + fileDownloadUrl(item) { + const value = item?.admin_download_url || item?.download_url || item?.file_url; + if (value) return new URL(value, location.href).href; + return new URL(resolveApiPath(`/api/admin/files/${item.id}/download`), location.href).href; + }, + fileShareUrl(item) { + const value = item?.share_url || item?.public_url || item?.url; + if (value) return new URL(value, location.href).href; + const token = item?.share_token || item?.public_token || item?.token; + if (token) return new URL(resolveApiPath(`/api/files/${encodeURIComponent(token)}`), location.href).href; + return this.fileDownloadUrl(item); + }, + 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" }); }, + portalOverview(from, to) { + const query = new URLSearchParams({ from, to }); + return request(`/api/portal/overview?${query.toString()}`); + }, + portalFiles() { return request("/api/portal/files"); }, + portalFileUrl(value) { + if (!value) return ""; + const url = new URL(value, location.href); + if (url.origin === location.origin && url.pathname.startsWith("/api/")) { + const base = new URL(API_PREFIX, location.href); + url.protocol = base.protocol; url.host = base.host; + url.pathname = `${base.pathname}${url.pathname.slice(4)}`; + } + return url.href; + }, + portalCreateEvent(input, options) { return request("/api/portal/events", { method: "POST", headers: idempotencyHeaders("portal-event", input, options), body: input }); }, + portalUpdateEvent(id, patch) { return request(`/api/portal/events/${id}`, { method: "PATCH", body: patch }); }, + portalDeleteEvent(id) { return request(`/api/portal/events/${id}`, { method: "DELETE" }); }, notifications() { return remoteOrLocal(() => request("/api/admin/notifications"), () => ({ items: structuredClone(localState.notifications) })); }, testNotification(options = {}) { return remoteOrLocal(() => request("/api/admin/notifications/test", { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notification-test") } }), () => createItem("notifications", "notification", { kind: "test", status: "reserved", scheduled_for: new Date().toISOString(), payload: { title: "테스트 알림" } })); }, }; diff --git a/apps/classbot/src/components/AppShell.jsx b/apps/classbot/src/components/AppShell.jsx index 4adc96b..e73cbbf 100644 --- a/apps/classbot/src/components/AppShell.jsx +++ b/apps/classbot/src/components/AppShell.jsx @@ -1,6 +1,6 @@ import { Bell, CalendarDays, ChevronDown, ClipboardList, Clock3, - LayoutDashboard, Megaphone, Plus, Settings, Users, X, + FolderOpen, LayoutDashboard, Megaphone, Plus, Settings, Users, X, } from "lucide-react"; const navItems = [ @@ -8,13 +8,14 @@ const navItems = [ ["events", "일정", CalendarDays], ["timetable", "시간표", ClipboardList], ["notices", "반 공지", Megaphone], + ["files", "자료실", FolderOpen], ["members", "구성원", Users], ["notifications", "알림 기록", Bell], ["settings", "설정", Settings], ]; export function Brand() { - return
Quilo
; + return
Quilo schedule
; } export function Sidebar({ active, onNavigate, classroom, memberCount }) { diff --git a/apps/classbot/src/pages/FilesPage.jsx b/apps/classbot/src/pages/FilesPage.jsx new file mode 100644 index 0000000..5c286c9 --- /dev/null +++ b/apps/classbot/src/pages/FilesPage.jsx @@ -0,0 +1,113 @@ +import { useMemo, useRef, useState } from "react"; +import { + Copy, ExternalLink, FileImage, FileText, FolderOpen, LoaderCircle, + LockKeyhole, Trash2, Upload, Users, X, +} from "lucide-react"; +import { dateLabel } from "../lib/format.js"; + +const ACCEPTED_TYPES = "application/pdf,image/jpeg,image/png,image/webp,image/gif"; +const ACCEPTED_EXTENSIONS = /\.(pdf|png|jpe?g|webp|gif)$/i; + +function isAcceptedFile(file) { + return ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"].includes(file?.type) || ACCEPTED_EXTENSIONS.test(file?.name || ""); +} + +function originalName(item) { + return item.original_name || item.original_filename || item.file_name || item.filename || "파일"; +} + +function fileMime(item) { + return item.mime_type || item.content_type || ""; +} + +function fileSize(item) { + const value = Number(item.size_bytes ?? item.size ?? item.file_size ?? item.byte_size); + if (!Number.isFinite(value) || value < 0) return "용량 정보 없음"; + if (value < 1024) return `${value} B`; + if (value < 1024 ** 2) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`; + 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 }) { + const inputRef = useRef(null); + const [file, setFile] = useState(null); + const [alias, setAlias] = useState(""); + const [description, setDescription] = useState(""); + const [memberId, setMemberId] = useState(""); + const [fileError, setFileError] = useState(""); + const targetOptions = useMemo(() => members.filter((member) => member.status !== "left"), [members]); + const memberNames = useMemo(() => new Map(members.map((member) => [member.id, member.display_name])), [members]); + const ordered = useMemo(() => [...files].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)), [files]); + + const chooseFile = (nextFile) => { + if (!nextFile) return; + if (!isAcceptedFile(nextFile)) { + setFileError("PDF, JPEG, PNG, WebP 또는 GIF 파일만 업로드할 수 있습니다."); + return; + } + setFile(nextFile); + setFileError(""); + if (!alias.trim()) setAlias(nextFile.name.replace(/\.[^.]+$/, "")); + }; + + const reset = () => { + setFile(null); setAlias(""); setDescription(""); setMemberId(""); setFileError(""); + if (inputRef.current) inputRef.current.value = ""; + }; + + const submit = async (event) => { + event.preventDefault(); + if (!file) { setFileError("업로드할 파일을 선택해 주세요."); return; } + if (!alias.trim() || busy) return; + const saved = await onUpload({ file, alias: alias.trim(), description: description.trim(), member_id: memberId || null }); + if (saved !== false) reset(); + }; + + return ( +
+

자료실

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

+ +
+

자료 업로드

PDF · 이미지
+
+
+ chooseFile(event.target.files?.[0])} disabled={busy} /> + + {file &&
{file.name}{fileSize(file)}
} + {fileError &&

{fileError}

} +
+ +
+ +