A task tracker for CodeWithPurpose (CWP) that can be used from a website or from Discord slash commands, with both surfaces sharing one permission-enforcing core.
-
Shared core (
lib/core/*): domain types, Supabase data access for members/tasks, and the permission rules (lib/core/permissions.ts). Both the website's server actions (app/actions/tasks.ts) and the Discord interaction handler (lib/discord/handler.ts) call into this same core, so a rule only has to be written and tested once and it is enforced identically from both surfaces. -
Website (Next.js App Router): sign in with Discord via Supabase Auth, view/create/assign/complete tasks, browse the org chart (
app/page.tsx,app/my-tasks/page.tsx,app/org/page.tsx,app/tasks/[id]/page.tsx). -
Discord bot: slash commands (
/task-create,/task-list,/mytasks,/task-assign,/task-status,/task-complete,/task-remove) handled by a single webhook route (app/api/discord/interactions/route.ts) that verifies the Ed25519 request signature and dispatches into the same core. -
CWP hierarchy — every member has a
level(1–4) and, except Co-founders, adepartment:Level Role Scope 1 Co-founder org-wide ( department: null)2 Director one department 3 Lead one department 4 Team Member one department Lower
levelnumber = higher rank (outranks(actor, other)isactor.level < other.level). -
Delete is gated by hierarchy (
canDeleteinlib/core/permissions.ts):- A Co-founder (level 1) can delete any task.
- A Director (level 2) can delete a task only if every one of its assignees is in the Director's own department.
- Leads and Team Members can never delete, regardless of department or whether they created/are assigned to the task.
This is unit-tested exhaustively in
lib/core/permissions.test.tsandlib/core/tasks.test.ts, and the same computedcanDeleteboolean is whatapp/tasks/[id]/page.tsxpasses tocomponents/task-actions.tsx, which is the only place the Delete button is rendered — so the E2E test intests/e2e/permissions.spec.tsexercises it end-to-end through the UI rather than just the function.
- Node.js 20+
- A Supabase project (this project targets a
hosted Supabase project — there is no local Supabase stack; schema and
seed data are applied with
psqlagainst the hosted database). - A Discord application (for OAuth login and the slash-command bot).
psql(PostgreSQL client) available on yourPATH, to apply migrations.
npm installCopy .env.example to .env.local and fill in the values below.
.env.local is git-ignored — never commit it.
cp .env.example .env.local| Variable | Where to get it |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project → Settings → API → Project URL (e.g. https://YOURREF.supabase.co) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase project → Settings → API → anon public key |
SUPABASE_SERVICE_ROLE_KEY |
Supabase project → Settings → API → service_role key (server-only, never exposed to the browser) |
SUPABASE_DB_URL |
Supabase project → Settings → Database → Connection string (URI, direct connection). Used for psql migrations/seed and by the integration tests in lib/core/members.test.ts / lib/core/tasks.test.ts |
DISCORD_APP_ID |
Discord Developer Portal → your application → General Information → Application ID |
DISCORD_PUBLIC_KEY |
Discord Developer Portal → your application → General Information → Public Key |
DISCORD_BOT_TOKEN |
Discord Developer Portal → your application → Bot → Token |
This project does not use supabase db reset / the local Supabase CLI stack.
Instead, the migration and seed SQL files are applied directly to the hosted
project with psql:
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f supabase/migrations/0001_init.sql
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f supabase/seed.sqlsupabase/seed.sql inserts the 14 CWP members with placeholder Discord
ids (REPLACE_shreyan, REPLACE_dir_ops, REPLACE_lead1, …). Before
go-live, replace each placeholder with the member's real Discord user id
(right-click their name in Discord with Developer Mode enabled → "Copy User
ID"), e.g.:
update members set discord_id = '123456789012345678' where display_name = 'Shreyan Mitra';Repeat for every seeded member. Login and Discord-command lookups both key
off discord_id, so a member won't resolve to anything until this is done.
In the Supabase dashboard: Authentication → Providers → Discord.
- Enable the provider.
- Set the Client ID / Client Secret from your Discord application (Developer Portal → OAuth2 → General).
- In Discord Developer Portal → OAuth2 → General, add a redirect URL of
<your Supabase project URL>/auth/v1/callback(Supabase shows the exact value to paste on the same provider settings page). - The app's own callback route is
app/auth/callback/route.ts, reachable at<site>/auth/callback— this is where Supabase redirects back to after the Discord OAuth handshake completes; no further configuration is needed for it beyond what Supabase's provider setup already handles.
-
In the Discord Developer Portal, copy the Application ID, Public Key, and Bot Token into
.env.local(see the table above). -
Under General Information, set the Interactions Endpoint URL to:
<site>/api/discord/interactionse.g.
https://yourapp.vercel.app/api/discord/interactionsin production, or a tunneled URL (ngrok/Cloudflare Tunnel) pointed athttp://localhost:3000/api/discord/interactionsfor local testing. Discord will send aPINGto this URL and expects an immediate signedPONG;app/api/discord/interactions/route.tsverifies the request signature withlib/discord/verify.tsbefore dispatching, so this only succeeds onceDISCORD_PUBLIC_KEYis set correctly. -
Under Bot, ensure the bot has been added to your Discord server (OAuth2 → URL Generator → scope
botandapplications.commands, then visit the generated URL). -
Register the slash commands defined in
lib/discord/commands.ts:set -a && source .env.local && set +a && npm run register-commands
This does a bulk overwrite
PUTof all commands for your application, so it's safe to re-run wheneverlib/discord/commands.tschanges.
npm run devVisit http://localhost:3000, log in with Discord, and the app redirects to
/login for unauthenticated visitors and back to / on success.
npm testWARNING:
npm testruns integration suites that write to the Supabase project configured in.env.local(they create real rows, then clean up only the rows they created). Point.env.localat a dedicated dev/test Supabase project — never one holding real member or task data you care about.
This runs Vitest against everything in lib/**/*.test.ts. vitest.config.ts
loads vitest.setup.ts, which reads .env.local via dotenv before any
test file is collected — so npm test works out of the box with no manual
source .env.local step, and no test is silently skipped just because the
shell didn't have the environment sourced.
lib/core/permissions.test.ts,lib/discord/verify.test.ts,lib/discord/handler.test.tsare pure unit suites (no network).lib/core/members.test.tsandlib/core/tasks.test.tsare integration suites: they rundescribe.skipIf(!process.env.SUPABASE_SERVICE_ROLE_KEY)and, when the env is present, hit the real hosted Supabase database configured by.env.local— so they require steps 2–3 above (schema + seed applied) to pass.
With a fully configured .env.local, all suites run and pass — nothing is
skipped.
npm run e2eplaywright.config.ts runs tests in tests/e2e/ against
http://localhost:3000, starting npm run dev automatically if nothing is
already listening there (reuseExistingServer: true).
tests/e2e/permissions.spec.ts exercises the same delete-gating rule as the
unit tests, but through the real UI: it opens a seeded task's detail page as
a logged-in member and asserts whether the Delete button
(components/task-actions.tsx) is present. Because this needs a live
Supabase Auth session (obtained through the real Discord OAuth flow) and
a seeded task id, and neither is available in a fresh checkout or CI, the
test gracefully skips unless you provide:
| Env var | Meaning |
|---|---|
E2E_TASK_ID |
id of a seeded task assigned only to members outside a test Lead's department |
E2E_SUPABASE_SESSION |
JSON {"name": "...", "value": "..."} for the Supabase auth cookie of a seeded Lead session |
E2E_COFOUNDER_SESSION (optional) |
same shape, for a seeded Co-founder session — when set, an additional test asserts the Delete button is present |
Without these, npm run e2e reports the suite as skipped, not failed —
this is expected in a fresh environment.
How to obtain a session cookie value: run npm run dev, open
http://localhost:3000/login in a browser, log in as the Discord account
mapped to the member you want to test as (see step 3 for wiring real
discord_ids), then open DevTools → Application/Storage → Cookies for
localhost:3000. Supabase's SSR client stores the session in a cookie named
sb-<project-ref>-auth-token (it may be chunked into
sb-<project-ref>-auth-token.0, .1, … for large sessions — if so, use the
.0 chunk, or reduce cookie size by trimming unused OAuth scopes). Copy that
cookie's name and value into the JSON shape above, e.g.:
export E2E_TASK_ID=00000000-0000-0000-0000-000000000000
export E2E_SUPABASE_SESSION='{"name":"sb-abcdefgh-auth-token","value":"base64-..."}'
npm run e2enpx tsc --noEmitAll commands are defined in lib/discord/commands.ts, registered via
npm run register-commands, and dispatched by lib/discord/handler.ts into
the same lib/core/tasks.ts functions the website's server actions use — so
the permission rules below apply identically on Discord and on the website.
| Command | What it does | Permission rule enforced |
|---|---|---|
/task-create |
Create a task, optionally with an assignee | Anyone can create (canCreate); the actor is recorded as createdBy |
/task-list |
List tasks, optionally filtered by status/member | Anyone can view (canView) |
/mytasks |
List tasks assigned to the caller | Anyone can view their own tasks |
/task-assign |
Assign a member to a task | canAssign: actor can assign anyone at or below their own level; a Team Member (level 4) can only assign themselves |
/task-status |
Set a task's status | canChangeStatus: the task's creator, an assignee, or someone who outranks every current assignee |
/task-complete |
Shortcut for /task-status status:complete |
Same as /task-status |
/task-remove |
Delete a task | canDelete: Co-founder (any task), or Director whose department covers every assignee — Leads and Team Members never |
Any permission violation raises PermissionError in lib/core/tasks.ts,
which the handler turns into an ephemeral ⛔ <message> reply on Discord
(lib/discord/handler.ts) and a { ok: false, error } result on the website
(app/actions/tasks.ts), rather than a generic 500. Discord requests with an
invalid signature are rejected with HTTP 401 before any command logic runs
(app/api/discord/interactions/route.ts).
- Multiple assignees on create: the website's task-create form
(
components/task-form.tsx) supports selecting several assignees at once. The Discord/task-createcommand takes a singleassigneeoption — to add more assignees afterward, use/task-assignper member, or recreate the task from the website. - Post-creation assignment: adding/changing an assignee after a task
exists is currently only exposed via the Discord
/task-assigncommand. A dedicated "assign" control on the website is a planned follow-up.
lib/core/ domain types, Supabase clients, member/task data access, permissions
lib/discord/ Discord signature verification, command defs, interaction handler, formatting
app/ Next.js App Router pages, server actions, auth callback, Discord webhook route
components/ client components (task form, task card, task actions, status badge)
supabase/ migrations/0001_init.sql (schema + RLS), seed.sql (14 CWP members)
scripts/ register-commands.ts (bulk-registers Discord slash commands)
tests/e2e/ Playwright E2E specs