Version: 1.1 Stack: FastAPI + Google Sheets + SQLite + Docker + DigitalOcean + Linear + Discord Program: Cyber Defenders Summer 2026 Internship
A lightweight intern tracking portal for ~25 interns across 7 cybersecurity project tracks. Coordinator, sponsors, and interns each have role-appropriate views. Google Sheets is the system of record; auth/notes/issue cache live in SQLite. Linear manages intern tasks; Discord delivers automated DMs.
Goals:
- Track weekly check-ins, deliverables, attendance, and mentor feedback
- Allow coordinator to send templated email reminders to interns or track groups
- Surface a public read-only dashboard of tracks, interns, and deliverables
- Tracks and sponsor info managed in Google Sheets — no code changes needed to update them
- ~$0 additional infrastructure cost, co-hosted with classapp on existing droplet
| Principle | Description |
|---|---|
| Sheets-first | Google Sheets is the primary database for all program data |
| Role-aware | Four auth tiers: intern, mentor, admin, sponsor |
| Human-readable | Coordinator can inspect all state manually in Sheets |
| Low friction | Magic link auth, no passwords |
| Brand-consistent | Matches Cyber Defenders Program visual identity |
| Debuggable | Everything accessible via SSH |
Browser
│
▼ HTTPS :443
┌─────────────────────┐
│ Nginx (host) │ ← TLS termination, routes intern.* subdomain
└─────────────────────┘
│ HTTP :8001 (localhost)
▼
┌─────────────────────┐
│ Docker container │
│ ┌───────────────┐ │
│ │ FastAPI │ │
│ └───────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ SQLite Sheets │
└─────────────────────┘
Co-hosted with classapp on the same DigitalOcean droplet. classapp runs on port 8000; internapp runs on port 8001. Nginx routes intern.cyberdefendersprogram.com to port 8001.
| Component | Purpose |
|---|---|
| Nginx (host) | TLS termination, reverse proxy, subdomain routing |
| Docker | Runtime isolation |
| FastAPI | Application server |
| SQLite | Magic tokens, rate limits, intern cache, Linear issue cache, meeting notes |
| Google Sheets | All program data (roster, tracks, check-ins, deliverables, etc.) |
| GitHub Actions | CI/CD pipeline |
| ForwardEmail API | Magic links + email reminders (api.forwardemail.net/v1/emails) |
| Linear | Task/issue management — GraphQL API + webhooks |
| Discord Bot API | DM delivery for task notifications and check-in reminders |
| APScheduler | Thursday noon PT check-in reminder cron job |
| Variable | Description | Example |
|---|---|---|
SECRET_KEY |
JWT signing key (32+ chars) | your-secret-key-here |
GOOGLE_SHEETS_ID |
Spreadsheet ID from URL | 1BxiMVs0XRA5nFMdKvBd... |
GOOGLE_SERVICE_ACCOUNT_PATH |
Path to service account JSON | /etc/internapp/sa.json |
FORWARDEMAIL_USER |
ForwardEmail API username (sending address) | noreply@cyberdefendersprogram.com |
FORWARDEMAIL_PASS |
ForwardEmail API key | your-api-key |
BASE_URL |
Public URL (no trailing slash) | https://intern.cyberdefendersprogram.com |
ADMIN_EMAILS |
Comma-separated admin email list | vaibhavb@gmail.com |
| Variable | Default | Description |
|---|---|---|
ENV |
production |
development or production |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
SQLITE_PATH |
/var/lib/internapp/app.db |
SQLite database path |
PORT |
8001 |
HTTP port inside container |
DISCORD_CDPBOT_TOKEN |
— | Discord bot token — required for DM reminders |
LINEAR_API_KEY |
— | Linear personal API key — required for issue sync |
LINEAR_WEBHOOK_SECRET |
— | Linear webhook signing secret |
LINEAR_TEAM_ID |
9e576d33-... |
Linear team ID (pre-configured) |
| Spec | Value |
|---|---|
| Type | Basic Droplet (existing) |
| vCPU | 1 |
| RAM | 1 GiB |
| OS | Ubuntu 24.04 LTS |
| Additional cost | $0 (shared with classapp) |
Peak concurrent users expected: 25.
/opt/internapp/ # Application root
├── docker-compose.yml
└── env/
└── .env
/var/lib/internapp/ # Persistent data
└── app.db # SQLite database
/etc/internapp/ # Secrets
└── service-account.json
On every push to main:
GitHub Actions
│
├─► Build Docker image
├─► Push to GHCR
├─► SSH to droplet
├─► docker compose pull
└─► docker compose up -d
server {
listen 80;
server_name intern.cyberdefendersprogram.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name intern.cyberdefendersprogram.com;
ssl_certificate /etc/letsencrypt/live/intern.cyberdefendersprogram.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/intern.cyberdefendersprogram.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}| Role | How Identified | Landing Page | Access |
|---|---|---|---|
admin |
Email in ADMIN_EMAILS env var, or Roster row with role=admin |
/admin |
Full access to all data and admin routes |
mentor |
Roster row with role=mentor and preferred_email set |
/admin/applicants |
Applicant review; read own track's interns |
sponsor |
Roster row with role=sponsor, or email matches sponsor_email in Tracks sheet |
/sponsor |
Read own track's interns; submit feedback |
intern |
Roster row with role=intern and a claimed preferred_email |
/home |
Own check-ins, deliverables, profile |
Role is determined at login time and stored in the JWT payload. Admin, mentor, and sponsor rows must have preferred_email pre-populated by an admin in the Roster sheet — these roles never go through the intern claim flow.
User enters email
│
▼
Magic link sent (15 min TTL)
│
▼
User clicks link
│
├── Admin email (ADMIN_EMAILS env) → role=admin → /admin
├── Roster row role=admin (preferred_email match) → role=admin → /admin
├── Roster row role=mentor (preferred_email match) → role=mentor → /admin/applicants
├── Roster row role=sponsor (preferred_email match) → role=sponsor → /sponsor
├── Tracks sheet sponsor_email match → role=sponsor → /sponsor
├── Roster row role=intern, already claimed → role=intern → /home (or /onboarding)
└── Email not recognised → sign-in page with "not registered" error
Admin, mentor, and sponsor emails must be pre-populated in the Roster (preferred_email)
or Tracks sheet (sponsor_email) by a program admin before first login.
| Property | Value |
|---|---|
| Storage | HTTP-only secure cookie |
| Format | Signed JWT |
| Cookie name | session |
| TTL | 7 days |
| JWT payload | {email, intern_id, role, exp} |
Magic link requests are rate-limited generously to avoid blocking legitimate users (prior classapp experience showed tight limits caused problems with shared IPs and slow email clients).
| Limit | Value |
|---|---|
| Requests per email per 15 min | 10 |
| Requests per IP per 15 min | 20 |
- No passwords stored
- Magic tokens: one-time use, 15 min TTL
- Intern ID required for initial claim (must exist in Roster)
- Sponsors and admins: email must already be in Tracks sheet or
ADMIN_EMAILS - All traffic over TLS
Single spreadsheet with these tabs:
One row per user (intern, mentor, admin, or sponsor). Admin pre-populates all fields. For interns, preferred_email is left blank and filled on first login; for all other roles, preferred_email must be pre-set by admin.
| Column | Type | Source |
|---|---|---|
intern_id |
string | Admin (e.g., CDP-2026-001) |
full_name |
string | Admin (e.g., Bhandari, Vaibhav) |
track_id |
string | Admin — single ID for interns (e.g., track-1); comma-separated for multi-track mentors/sponsors (e.g., track-1,track-3) |
role |
string | Admin — intern (default), mentor, admin, or sponsor |
preferred_email |
string | Interns: blank until claimed. Mentor/admin/sponsor: pre-set by admin. |
preferred_name |
string | Intern (optional) |
school |
string | Intern |
year |
string | Intern (Freshman, Sophomore, etc.) |
linkedin |
string | Intern (optional) |
github |
string | Intern (optional) |
bio |
string | Intern (short intro, optional) |
claimed_at |
ISO timestamp | System |
onboarding_completed_at |
ISO timestamp | System |
last_login_at |
ISO timestamp | System |
discord_id |
string | Set by /link flow; blank until linked |
discord_notify |
boolean | true default; set to false via /notify off |
linear_user_id |
string | Linear UUID — set by admin Sync Linear IDs action after intern accepts invite |
Constraints:
intern_idmust be uniquerolemust be one of:intern,mentor,admin,sponsor(defaults tointernif blank/invalid)- For
internrows:preferred_emailblank until claimed, immutable after;track_idis a single value - For
mentor/admin/sponsorrows:preferred_emailmust be set before first login;track_idmay be comma-separated for multi-track assignment - Each
track_idvalue must match atrack_idin theTrackssheet
One row per project track. Admin manages this directly in Sheets; no code changes required to add, rename, or reassign tracks.
| Column | Type | Description |
|---|---|---|
track_id |
string | Unique key (e.g., track-1) |
name |
string | Display name |
description |
string | One-paragraph summary |
employer_sponsor |
string | Mentor full name |
sponsor_email |
string | Mentor email (used for login identity) |
status |
string | active or archived |
Append-only. One row per intern per week submission.
| Column | Type | Description |
|---|---|---|
submitted_at |
ISO timestamp | Submission time |
intern_id |
string | FK to Roster |
email |
string | Intern email |
week_number |
integer | Program week (1–6, computed from program_start_date) |
status_update |
string | What I did this week |
blockers |
string | Current blockers (optional) |
next_steps |
string | Plan for next week |
Append-only. Interns submit links or descriptions of artifacts they produce.
| Column | Type | Description |
|---|---|---|
submitted_at |
ISO timestamp | Submission time |
intern_id |
string | FK to Roster |
track_id |
string | FK to Tracks |
week_number |
integer | Program week (1–6) |
title |
string | Short artifact name |
url |
string | Link to artifact (GitHub, doc, etc.) |
description |
string | What it is and how to use it |
Admin or coordinator fills this. One row per intern per session.
| Column | Type | Description |
|---|---|---|
session_date |
ISO date | Date of sync call or in-person session |
session_type |
string | weekly-sync, in-person, kickoff, demo-day |
intern_id |
string | FK to Roster |
present |
boolean | TRUE / FALSE |
notes |
string | Optional note |
Sponsor or admin submits. One row per intern per review.
| Column | Type | Description |
|---|---|---|
submitted_at |
ISO timestamp | Submission time |
intern_id |
string | FK to Roster |
week_number |
integer | Program week |
reviewer_email |
string | Who submitted |
rating |
integer | 1–5 scale |
feedback |
string | Qualitative notes |
Audit log of all emails sent from the app.
| Column | Type | Description |
|---|---|---|
sent_at |
ISO timestamp | Send time |
sender_email |
string | Admin who triggered it |
recipient_email |
string | Recipient |
recipient_name |
string | Display name |
subject |
string | Email subject |
template |
string | Template slug used |
status |
string | sent or failed |
note |
string | Error detail if failed |
Append-on-create; status updated in-place. One row per task per intern.
| Column | Type | Description |
|---|---|---|
task_id |
string | UUID — stable across web and Discord |
title |
string | Short task name |
description |
string | Markdown body (optional) |
task_type |
string | system, assigned, or self |
assigned_to |
string | intern_id, track:track-1, or all |
assigned_by |
string | intern_id or system |
track_id |
string | FK → Tracks (optional) |
week_number |
integer | Which week the task belongs to |
due_week |
integer | Week the task is due |
status |
string | todo, done, or skipped |
priority |
string | normal or high |
linked_feature |
string | checkin, deliverable, onboarding, or blank — auto-completes on feature submit |
source |
string | web, discord, or system |
skip_reason |
string | Required when status = skipped |
created_at |
ISO timestamp | Creation time |
completed_at |
ISO timestamp | Completion time (set on done/skipped) |
Admin edits once. The seed script fans these out to individual intern rows in Tasks at program start. They define the default 6-week curriculum so admins don't create tasks one-by-one for every intern.
| Column | Type | Description |
|---|---|---|
template_id |
string | Unique key |
title |
string | |
description |
string | |
task_type |
string | |
assigned_to |
string | all, role:intern, or track:track-1 |
week_number |
integer | |
due_week |
integer | |
priority |
string | |
linked_feature |
string |
Key-value configuration. Admin edits directly in Sheets.
| Key | Example Value | Description |
|---|---|---|
program_title |
Cyber Defenders Summer 2026 |
Displayed in UI headers |
program_start_date |
2026-06-15 |
Week 1 start (used to compute week_number) |
program_weeks |
6 |
Total program duration |
magic_link_ttl_minutes |
15 |
Token expiry |
rate_limit_per_email_15m |
10 |
Max magic link requests per email per window |
CREATE TABLE magic_tokens (
token_hash TEXT PRIMARY KEY,
email TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
status TEXT DEFAULT 'pending' -- pending, used, expired
);CREATE TABLE rate_limits (
key TEXT PRIMARY KEY, -- e.g., "email:user@example.com"
window_start TEXT NOT NULL,
count INTEGER DEFAULT 1
);Caches Roster rows (15 min TTL) to reduce Sheets API calls.
CREATE TABLE intern_cache (
intern_id TEXT PRIMARY KEY,
profile_json TEXT NOT NULL,
cached_at TEXT NOT NULL
);Stores mentor/admin meeting notes (not in Sheets because Sheets handles multi-line text poorly).
CREATE TABLE meeting_notes (
id TEXT PRIMARY KEY,
intern_id TEXT NOT NULL,
meeting_type TEXT NOT NULL DEFAULT 'mentor_1on1', -- mentor_1on1, sponsor_checkin, other
week_number INTEGER,
meeting_date TEXT,
notes TEXT NOT NULL DEFAULT '',
action_items TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'all', -- all (intern-visible) or mentor_admin
created_at TEXT NOT NULL,
updated_at TEXT
);Caches Linear issue state to avoid repeated API calls and support webhook-triggered updates.
CREATE TABLE linear_issues (
id TEXT PRIMARY KEY, -- Linear issue UUID
intern_id TEXT NOT NULL,
template_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'Todo',
state_type TEXT NOT NULL DEFAULT 'unstarted', -- unstarted, started, completed, cancelled
url TEXT NOT NULL DEFAULT '',
due_week INTEGER,
linked_feature TEXT NOT NULL DEFAULT '',
synced_at TEXT NOT NULL
);
CREATE INDEX idx_linear_issues_intern ON linear_issues(intern_id);Cache TTL: 5 minutes for issue state, 24 hours for the "mark done" endpoint.
Shown after first claim. Only fields that are empty in Roster are shown.
| Field | Type | Label |
|---|---|---|
preferred_name |
text | Preferred Name |
school |
text | School / College |
year |
select | Year (Freshman, Sophomore, Junior, Senior, Grad) |
linkedin |
url | LinkedIn Profile URL |
github |
url | GitHub Profile URL |
bio |
textarea | Short bio (2–3 sentences) |
- Update
Rosterrow with submitted fields - Set
onboarding_completed_at - Send
welcomeemail to intern - Invalidate cache for this intern
- Redirect to
/home
Admin can compose and send templated emails to any subset of interns directly from the app. All sends are logged to the Email_Log sheet.
| Audience | Description |
|---|---|
| All active interns | Everyone with preferred_email set |
| By track | All interns assigned to a specific track |
| Missing check-in | Interns who have not submitted a check-in for the current week |
| Single intern | One specific intern by name |
Stored as Jinja2 files in content/emails/. Subject and body are rendered with intern-specific context.
Context variables available in all templates:
| Variable | Value |
|---|---|
{{ intern_name }} |
Preferred name or first name from full_name |
{{ track_name }} |
Intern's assigned track name |
{{ sponsor_name }} |
Track's employer sponsor |
{{ week_number }} |
Current program week |
{{ checkin_url }} |
Direct link to check-in form |
{{ deliverables_url }} |
Direct link to deliverables page |
{{ program_title }} |
From Config sheet |
{{ base_url }} |
App base URL |
Built-in templates:
| Slug | Subject | When Used |
|---|---|---|
welcome |
Welcome to {{ program_title }} |
Auto-sent on successful claim |
weekly-reminder |
Week {{ week_number }} check-in is open |
Manual send each week |
missing-checkin |
Don't forget your Week {{ week_number }} check-in |
Send to interns who haven't submitted |
custom |
Admin-entered subject | Free-form admin message |
Admin → /admin/email
│
├── Select audience
├── Select or write template
├── Preview rendered email (one sample recipient)
├── Confirm send
│
▼
App iterates recipients, sends via ForwardEmail REST API
│
▼
Each send logged to Email_Log sheet
│
▼
Admin sees summary: N sent, M failed
- Admin-only — sponsors and interns cannot trigger bulk email
- Cap: no more than 50 emails per single send action
- All sends logged to
Email_Logfor audit welcomeemail sent automatically on successful claim
| Method | Path | Description |
|---|---|---|
| GET | / |
Sign-in page |
| GET | /program |
Public dashboard: tracks + interns + deliverables |
| GET | /health |
Health check |
| Method | Path | Description |
|---|---|---|
| POST | /auth/request-link |
Send magic link |
| GET | /auth/verify |
Validate token, create session, redirect by role |
| POST | /auth/logout |
Clear session |
| Method | Path | Description |
|---|---|---|
| GET | /home |
Dashboard (intern view); admin/mentor/sponsor are redirected to their own pages |
| GET | /onboarding |
Onboarding form |
| POST | /onboarding |
Submit onboarding |
| GET | /checkin |
Current week check-in form (or past submissions if already done) |
| POST | /checkin |
Submit check-in |
| GET | /deliverables |
View own deliverables + submission form |
| POST | /deliverables |
Submit new deliverable |
| GET | /me |
Profile view/edit |
| POST | /me |
Update profile |
| Method | Path | Description |
|---|---|---|
| GET | /sponsor |
Track overview: interns, check-in status, deliverables |
| GET | /sponsor/intern/{intern_id} |
Individual intern detail |
| GET | /sponsor/feedback |
Feedback form |
| POST | /sponsor/feedback |
Submit mentor feedback |
| Method | Path | Description |
|---|---|---|
| GET | /admin |
All interns grouped by track, program-wide progress overview |
| GET | /admin/intern/{intern_id} |
Individual intern detail |
| GET | /admin/intern/{intern_id}/preview |
Preview intern dashboard as that user (without resetting token) |
| GET | /admin/attendance |
Attendance log + entry form |
| POST | /admin/attendance |
Log attendance |
| GET | /admin/email |
Email composer |
| POST | /admin/email/preview |
Render preview for one sample recipient |
| POST | /admin/email/send |
Send emails, log results |
| GET | /admin/linear |
Linear overview: all interns, issues, and assignment status |
| POST | /admin/linear/sync-ids |
Look up interns by email in Linear, save linear_user_id to Roster |
| POST | /admin/linear/fix-assignees |
Patch unassigned Linear issues to the correct intern |
| POST | /admin/discord/send-reminders |
Manually DM interns who haven't submitted a check-in this week |
| Method | Path | Description |
|---|---|---|
| POST | /api/linear/issues/{issue_id}/done |
Intern marks their Linear issue as Done |
| POST | /api/linear/webhook |
Linear webhook receiver — delivers Discord DMs on issue/comment events |
Authenticated with Authorization: Bearer <DISCORD_CDPBOT_TOKEN> header. Used by the Discord bot (OpenClaw).
| Method | Path | Description |
|---|---|---|
| GET | /api/bot/tasks |
Tasks for a linked Discord user (?discord_id=...) |
| POST | /api/bot/tasks |
Create a task from Discord |
| PATCH | /api/bot/tasks/{task_id} |
Update task status from Discord |
| Method | Path | Description |
|---|---|---|
| GET | /auth/discord-link |
Complete Discord identity linking (?token=...&discord_id=...) |
No auth required. Shows:
- Program header: title, dates, track count, active intern count
- Each active track: name, sponsor name, description
- Per track: list of interns (preferred name or first name) and their submitted deliverables (title + URL)
- Interns without claimed accounts shown as "TBD"
- Deliverables without a URL shown as text only
Not exposed publicly: check-in content, mentor feedback, attendance, ratings, email addresses.
In-memory cache (single process). Invalidate on writes.
| Data | TTL | Invalidate On |
|---|---|---|
| Config values | 5 min | — |
| Tracks list | 10 min | — |
| Roster by email | 2 min | claim, onboarding, profile update |
| Roster by intern_id | 2 min | same |
| Check-ins for intern | 2 min | check-in submit |
| Deliverables for intern | 2 min | deliverable submit |
| All interns (admin/public view) | 2 min | any roster write |
The app matches the Cyber Defenders Program visual identity used on cyberdefendersprogram.com. This means:
CSS Framework: Bulma (loaded from CDN, same version as the main site)
Fonts:
- Headings:
Roboto Mono(Google Fonts) — same as.is-hackathon-h2on the main site - Body:
Lato(Google Fonts) — same as the main site body font
Color palette (from main.css):
| Name | Hex | Use |
|---|---|---|
| CDP Navy | #062F49 |
Page headers, navbar background |
| CDP Blue | #5893BC |
Section headings, card borders, accent |
| CDP Salmon | #FA7C91 |
CTA buttons, card top/left borders, highlights |
| CDP Light | #EFF3F4 |
Alternating section backgrounds |
| CDP Footnote | #F1F3FA |
Apply-section and footer backgrounds |
Logo: The CDP logo (logo.png) and text lockup (logo-title.png) are served as static assets copied from the www-homepage repo into app/static/images/. The navbar uses the same logo shown on cyberdefendersprogram.com.
Navbar (all authenticated pages):
- Navy (
#062F49) background - CDP logo left-aligned
- Nav links right: role-appropriate (Home / Track / Profile / Logout)
- Responsive burger menu on mobile
Cards: Bulma .card with:
border-top: 4px solid #5893BC(detail cards)border-left: 4px solid #FA7C91(track/highlight cards).headinglabels in#5893BC/ Roboto Mono
Section backgrounds: Alternate between #EFF3F4 and #fff, matching intern.html pattern
Buttons:
- Primary CTA: Bulma
.button.is-primary→ renders as#FA7C91(salmon) per site's Bulma overrides - Secondary: Bulma
.button.is-light - Destructive / admin: Bulma
.button.is-danger
Flash messages: Bulma .notification with .is-success, .is-warning, .is-danger
Forms: Bulma .field / .control / .input / .textarea / .select components
| Path | Role | Screen |
|---|---|---|
/ |
public | Sign-in: CDP-branded hero, email input |
/program |
public | Track + intern + deliverable dashboard |
/onboarding |
intern | Profile completion form |
/home |
intern | Dashboard: track card, week status, deliverable list (admin/mentor/sponsor are redirected) |
/checkin |
intern | Weekly check-in form |
/deliverables |
intern | Deliverable list + submit form |
/me |
intern | Profile view/edit |
/sponsor |
sponsor | Track overview table |
/sponsor/intern/{id} |
sponsor | Intern detail: check-ins, deliverables, feedback form |
/admin |
admin | All interns table with week status indicators |
/admin/intern/{id} |
admin | Full intern detail |
/admin/attendance |
admin | Attendance sheet-style form |
/admin/email |
admin | Audience picker + template selector + preview + send |
Assets to copy from www-homepage at build time (or commit into app/static/):
| File | Source | Use |
|---|---|---|
logo.png |
assets/images/logo.png |
Navbar logo |
logo-title.png |
assets/images/logo-title.png |
Sign-in hero |
GotMalware.png |
assets/images/program/GotMalware.png |
Public dashboard section image |
code_stockphoto.jpg |
assets/images/program/code_stockphoto.jpg |
Sign-in hero background |
Structured JSON to stdout.
| Event | Level | Fields |
|---|---|---|
magic_link_sent |
INFO | |
magic_link_rate_limited |
WARN | email, count |
claim_success |
INFO | intern_id, email |
login_success |
INFO | intern_id, role |
checkin_submitted |
INFO | intern_id, week_number |
deliverable_submitted |
INFO | intern_id, week_number |
email_sent |
INFO | sender, template, recipient_count |
email_failed |
ERROR | recipient, error |
sheets_error |
ERROR | operation, error |
GET /health
{
"status": "ok",
"checks": {
"sqlite": true,
"sheets": true
},
"version": "1.0.0"
}Returns 200 if healthy, 503 if degraded.
- Admin creates Task Templates in Sheets (once per program).
- Admin runs "Fan out tasks" on
/admin/linear→ issues created in Linear per intern. - Admin runs "Sync Linear IDs" →
linear_user_idpopulated in Roster for interns who've joined Linear. - Admin runs "Fix assignees" → unassigned issues patched to correct interns.
- Interns work in Linear; state changes fire webhooks to the portal.
Linear event (issue assigned / completed / comment)
│
▼
POST /api/linear/webhook
│
├── Verify HMAC-SHA256 signature (LINEAR_WEBHOOK_SECRET)
├── Resolve intern from issue:
│ 1. SQLite cache (linear_issues table)
│ 2. assignee.id in payload → Roster.linear_user_id match
│ 3. Linear API fetch per intern (last resort)
│
▼
app.services.discord.send_dm(discord_id, message)
│
├── POST /users/@me/channels → open DM channel
└── POST /channels/{id}/messages → send message
Fires every Thursday at noon PT. DMss every intern whose discord_id is set and who hasn't submitted a check-in for the current week.
APScheduler cron (thu, 12:00 PT)
│
▼
jobs.reminders.send_checkin_reminders()
│
├── Compute current week_number from program_start_date
├── Get all interns with discord_id set
├── For each: check Check_ins sheet for this week
└── DM those who haven't submitted
Returns {week_number, sent, already_checked_in, failed}.
- Discord-native check-ins (standup modal as alternative to web form)
- Monday digest DMs (week summary + pending tasks)
- Intern-facing deliverable comments from sponsor
- PDF export of intern progress report
- Bulk attendance import from CSV
- 25 interns can log in simultaneously without rate-limit errors
- Magic link → claim → onboarding works end-to-end for interns
- Sponsors log in and see only their own track's interns; multi-track sponsors see all their tracks grouped
- Admins see all interns grouped by track and can log attendance and send email
- Email reminders render template variables correctly and log to
Email_Logsheet - Public
/programshows tracks + interns + deliverable links without auth - Track config changes in Sheets reflected within 10 minutes (cache TTL)
- UI matches CDP brand: navy navbar, Roboto Mono headings, salmon CTAs, Bulma cards
- CDP logo appears in navbar and sign-in page
- Push to
maindeploys within 5 minutes - Monthly infrastructure cost delta: $0 (shared droplet)
- Submitting a check-in or deliverable auto-completes the matching linked task
- Discord
/linkflow connects a Discord user to their Roster entry end-to-end - Bot API rejects requests without a valid
DISCORD_CDPBOT_TOKEN - Linear webhook receives events and verifies HMAC-SHA256 signature
- Comment on intern's Linear issue → Discord DM to intern
- Issue assigned in Linear → Discord DM to intern
- Thursday noon PT check-in reminder DMs interns who haven't submitted
- Admin "Sync Linear IDs" populates
linear_user_idfor workspace members - Admin "Fix assignees" patches unassigned Linear issues to correct interns
- Intern can mark their own Linear issue Done via portal
End of Specification