Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/classbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@

## 현재 구현 범위

- 관리자·학생 공통 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정
- 관리자 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정
- 학생 메뉴: 오늘, 일정, 공지, 자료실, 별도 `가상 주식` 탭, 구성원, 설정
- 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브
- 최대 16명 구성원과 기존 Quilo 로그인 세션 기반 접근 제어
- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `파일 리스트`, `김종수T 학습지`
- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `9월 23일 시간표`, `파일 리스트`, `김종수T 학습지`
- 파일명·별칭이 정확히 일치하면 바로 보내고, 비슷하면 최대 3개 후보를 물은 뒤 `맞아` 또는 번호 응답으로 보낸다.
- 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림
- 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다.
- 반 전체 자료는 Google Drive, 개인 자료는 비공개 Supabase 저장소에 보관하고 15분 프록시 링크로만 연다.
- Google Drive 운영 계정은 기존 Quilo의 `구민준` 계정을 정확한 이름으로 찾아 연결한다.
- 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림
- 수행평가 D-7·D-2·D-1·당일, 일정 변경, 평일 아침 시간표, 반 공지 Event API 알림
- Event API 작업 결과 조회와 사용자별 성공·실패 기록
- 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그
- 로컬 메모리 저장소와 운영 Supabase 저장소
- 실제 종목·시세·현금과 분리된 5개 허구 종목, 1,000 TKN 시작 잔액, 하루 1회 100 TKN 접속 보상, 구성원별 보유·거래 내역

## 2026학년도 2학기 시간표 응답 규칙

- 과목명은 카카오 응답에서 `수Ⅰ·수Ⅱ·수Ⅲ → 수학`, `독서 → 국어`, `영Ⅰ·영Ⅱ → 영어`, `체2 → 체육`, `확통1 → 확통`으로 통일한다.
- `공1·공2·공3·공강`은 선생님·강의실 값을 노출하지 않고 모두 `공강`으로 표시한다.
- 담당 선생님이 있으면 `일물(박홍T)` 형식으로 표시한다.
- 수요일 5~7교시는 `자율연구`로 표시한다. 외출 일정이 제공되기 전에는 `O/X`를 추정하지 않으며, 추후 제공 데이터로 `자율연구(O)` 또는 `자율연구(X)`를 결정한다.
- 화요일 7~9교시는 기본 `창체`다. 2026년 9월 1·8·15·22일, 10월 20·27일, 11월 17·24일에는 8~9교시를 `동아리`로 표시한다.
- 첨부된 `2026학년도 2학기 학사일정(안)`을 기준으로 공휴일·시험·방학식에는 정규 시간표를 표시하지 않고, 월·목·금요일 대체수업일에는 해당 요일의 개인 시간표를 사용한다.
- 학사일정표가 `(안)`이므로 일정 변경 시 `server/services/timetable-policy.js`의 날짜 정책과 테스트를 함께 갱신한다.

## 로컬 실행

Expand Down Expand Up @@ -45,7 +57,7 @@ npm run release:check

## 운영 배포

1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v5 운영 DB는 [`db/migrations/006_kakao_personal_event_actions.sql`](./db/migrations/006_kakao_personal_event_actions.sql)을 적용한다.
1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v6 운영 DB는 [`db/migrations/007_assessment_reminders_virtual_market.sql`](./db/migrations/007_assessment_reminders_virtual_market.sql)을 적용한다.
2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다.
3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다.
4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다.
Expand All @@ -61,7 +73,7 @@ npm run release:check
- Event 이름: 관리자센터 값과 `KAKAO_EVENT_NAME`을 정확히 동일하게 설정
- Event API 활성화: 준비가 끝난 뒤 `KAKAO_EVENT_ENABLED=true`
- 최초 이름 등록: 챗봇에 명단 이름 그대로 `이름 등록 구민준` 입력. 초대 코드는 필요 없다.
- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용
- 등록 후 조회: `오늘 일정`, `내일 시간표`, `9월 23일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용
- 개인 일정 변경: `내일 영어 과제 추가`, `수학 수행평가 22일로 변경`, `방금 일정 완료`처럼 입력하고 10분 안에 확인. 반 전체 일정은 카카오에서 변경할 수 없음
- 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
-- Classbot schema v7: 수행평가 고정 알림 정책과 TKN 전용 허구 가상 주식.

update public.classbot_events
set reminder_offsets = array[10080, 2880, 1440, 0], updated_at = now()
where category = 'assessment'
and reminder_offsets is distinct from array[10080, 2880, 1440, 0];

create or replace function public.classbot_enforce_assessment_reminders()
returns trigger
language plpgsql
set search_path = public
as $$
begin
if new.category = 'assessment' then
new.reminder_offsets := array[10080, 2880, 1440, 0];
end if;
return new;
end;
$$;

drop trigger if exists enforce_assessment_reminders_insert on public.classbot_events;
create trigger enforce_assessment_reminders_insert
before insert on public.classbot_events
for each row execute function public.classbot_enforce_assessment_reminders();
drop trigger if exists enforce_assessment_reminders_update on public.classbot_events;
create trigger enforce_assessment_reminders_update
before update of category, reminder_offsets on public.classbot_events
for each row execute function public.classbot_enforce_assessment_reminders();

create table if not exists public.classbot_token_accounts (
class_id uuid not null references public.classbot_classes(id) on delete cascade,
member_id uuid not null references public.classbot_members(id) on delete cascade,
balance bigint not null default 1000 check (balance >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (class_id, member_id),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade
);

create table if not exists public.classbot_token_ledger (
id uuid primary key default gen_random_uuid(),
class_id uuid not null references public.classbot_classes(id) on delete cascade,
member_id uuid not null references public.classbot_members(id) on delete cascade,
kind text not null check (kind in ('daily_reward', 'buy', 'sell')),
amount bigint not null check (amount <> 0),
balance_after bigint not null check (balance_after >= 0),
reference_key text not null check (char_length(reference_key) between 1 and 180),
metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'),
created_at timestamptz not null default now(),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade,
unique (class_id, member_id, reference_key)
);

create table if not exists public.classbot_market_positions (
class_id uuid not null references public.classbot_classes(id) on delete cascade,
member_id uuid not null references public.classbot_members(id) on delete cascade,
symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')),
quantity integer not null default 0 check (quantity >= 0),
average_cost bigint not null default 0 check (average_cost >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (class_id, member_id, symbol),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade
);

create table if not exists public.classbot_market_trades (
id uuid primary key default gen_random_uuid(),
class_id uuid not null references public.classbot_classes(id) on delete cascade,
member_id uuid not null references public.classbot_members(id) on delete cascade,
symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')),
side text not null check (side in ('buy', 'sell')),
quantity integer not null check (quantity between 1 and 1000),
price bigint not null check (price > 0),
total bigint not null check (total = quantity::bigint * price),
request_key text not null check (char_length(request_key) between 1 and 160),
created_at timestamptz not null default now(),
foreign key (class_id, member_id)
references public.classbot_members(class_id, id) on delete cascade,
unique (class_id, member_id, request_key)
);

create index if not exists classbot_token_ledger_member_created_idx
on public.classbot_token_ledger(class_id, member_id, created_at desc);
create index if not exists classbot_market_trades_member_created_idx
on public.classbot_market_trades(class_id, member_id, created_at desc);

create or replace function public.classbot_initialize_token_account()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.classbot_token_accounts(class_id, member_id, balance)
values (new.class_id, new.id, 1000)
on conflict (class_id, member_id) do nothing;
return new;
end;
$$;

drop trigger if exists initialize_token_account on public.classbot_members;
create trigger initialize_token_account
after insert on public.classbot_members
for each row execute function public.classbot_initialize_token_account();
revoke all on function public.classbot_initialize_token_account() from public;

insert into public.classbot_token_accounts(class_id, member_id, balance)
select class_id, id, 1000 from public.classbot_members
on conflict (class_id, member_id) do nothing;

create or replace function public.classbot_claim_daily_market_reward(
p_class_id uuid,
p_member_id uuid,
p_reward_date date,
p_amount integer
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
selected_timezone text;
selected_account public.classbot_token_accounts%rowtype;
created_ledger public.classbot_token_ledger%rowtype;
reward_key text := 'daily:' || p_reward_date::text;
begin
if p_amount < 1 or p_amount > 1000 then
raise exception '접속 보상 금액이 올바르지 않습니다.';
end if;
select c.timezone into selected_timezone
from public.classbot_classes c
join public.classbot_members m on m.class_id = c.id
where c.id = p_class_id and m.id = p_member_id and m.status <> 'left';
if selected_timezone is null then
raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.';
end if;
if p_reward_date <> (clock_timestamp() at time zone selected_timezone)::date then
raise exception '오늘 접속 보상만 받을 수 있습니다.';
end if;

insert into public.classbot_token_accounts(class_id, member_id, balance)
values (p_class_id, p_member_id, 1000)
on conflict (class_id, member_id) do nothing;
select * into selected_account
from public.classbot_token_accounts
where class_id = p_class_id and member_id = p_member_id
for update;

if exists (
select 1 from public.classbot_token_ledger
where class_id = p_class_id and member_id = p_member_id and reference_key = reward_key
) then
return jsonb_build_object('claimed', false, 'balance', selected_account.balance);
end if;

update public.classbot_token_accounts
set balance = balance + p_amount
where class_id = p_class_id and member_id = p_member_id
returning * into selected_account;
insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata)
values (p_class_id, p_member_id, 'daily_reward', p_amount, selected_account.balance, reward_key, jsonb_build_object('reward_date', p_reward_date))
returning * into created_ledger;
return jsonb_build_object('claimed', true, 'ledger', to_jsonb(created_ledger));
end;
$$;

create or replace function public.classbot_execute_market_trade(
p_class_id uuid,
p_member_id uuid,
p_symbol text,
p_side text,
p_quantity integer,
p_price bigint,
p_request_key text
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
selected_account public.classbot_token_accounts%rowtype;
selected_position public.classbot_market_positions%rowtype;
existing_trade public.classbot_market_trades%rowtype;
created_trade public.classbot_market_trades%rowtype;
order_total bigint;
next_quantity integer;
begin
p_symbol := upper(trim(p_symbol));
p_side := lower(trim(p_side));
p_request_key := trim(p_request_key);
if p_symbol not in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW') then raise exception '존재하지 않는 가상 종목입니다.'; end if;
if p_side not in ('buy', 'sell') then raise exception '매수 또는 매도를 선택해 주세요.'; end if;
if p_quantity < 1 or p_quantity > 1000 then raise exception '주문 수량은 1~1000주 사이여야 합니다.'; end if;
if p_price < 1 or p_price > 1000000 then raise exception '가상 종목 가격이 올바르지 않습니다.'; end if;
if p_request_key = '' or char_length(p_request_key) > 160 then raise exception '주문 식별값이 올바르지 않습니다.'; end if;
if not exists (
select 1 from public.classbot_members
where class_id = p_class_id and id = p_member_id and status <> 'left'
) then raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.'; end if;

insert into public.classbot_token_accounts(class_id, member_id, balance)
values (p_class_id, p_member_id, 1000)
on conflict (class_id, member_id) do nothing;
select * into selected_account
from public.classbot_token_accounts
where class_id = p_class_id and member_id = p_member_id
for update;

select * into existing_trade
from public.classbot_market_trades
where class_id = p_class_id and member_id = p_member_id and request_key = p_request_key;
if found then return to_jsonb(existing_trade); end if;

insert into public.classbot_market_positions(class_id, member_id, symbol, quantity, average_cost)
values (p_class_id, p_member_id, p_symbol, 0, 0)
on conflict (class_id, member_id, symbol) do nothing;
select * into selected_position
from public.classbot_market_positions
where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol
for update;

order_total := p_quantity::bigint * p_price;
if p_side = 'buy' then
if selected_account.balance < order_total then raise exception '보유 토큰이 부족해 매수할 수 없습니다.'; end if;
next_quantity := selected_position.quantity + p_quantity;
update public.classbot_market_positions
set quantity = next_quantity,
average_cost = round((selected_position.average_cost * selected_position.quantity + order_total)::numeric / next_quantity)::bigint
where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol;
update public.classbot_token_accounts
set balance = balance - order_total
where class_id = p_class_id and member_id = p_member_id
returning * into selected_account;
else
if selected_position.quantity < p_quantity then raise exception '보유 수량이 부족해 매도할 수 없습니다.'; end if;
next_quantity := selected_position.quantity - p_quantity;
update public.classbot_market_positions
set quantity = next_quantity,
average_cost = case when next_quantity = 0 then 0 else average_cost end
where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol;
update public.classbot_token_accounts
set balance = balance + order_total
where class_id = p_class_id and member_id = p_member_id
returning * into selected_account;
end if;

insert into public.classbot_market_trades(class_id, member_id, symbol, side, quantity, price, total, request_key)
values (p_class_id, p_member_id, p_symbol, p_side, p_quantity, p_price, order_total, p_request_key)
returning * into created_trade;
insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata)
values (
p_class_id, p_member_id, p_side,
case when p_side = 'buy' then -order_total else order_total end,
selected_account.balance, 'trade:' || p_request_key,
jsonb_build_object('trade_id', created_trade.id, 'symbol', p_symbol, 'quantity', p_quantity, 'price', p_price)
);
return to_jsonb(created_trade);
end;
$$;

revoke all on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) from public;
revoke all on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) from public;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'grant execute on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) to service_role';
execute 'grant execute on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) to service_role';
end if;
end;
$$;

drop trigger if exists set_updated_at on public.classbot_token_accounts;
create trigger set_updated_at before update on public.classbot_token_accounts
for each row execute function public.classbot_set_updated_at();
drop trigger if exists set_updated_at on public.classbot_market_positions;
create trigger set_updated_at before update on public.classbot_market_positions
for each row execute function public.classbot_set_updated_at();

alter table public.classbot_token_accounts enable row level security;
alter table public.classbot_token_ledger enable row level security;
alter table public.classbot_market_positions enable row level security;
alter table public.classbot_market_trades enable row level security;

insert into public.classbot_schema_meta(id, version, applied_at)
values (1, 7, now())
on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at;
Loading
Loading