🪻 distributed transcription service thistle.dunkirk.sh

Compare changes

Choose any two refs to compare.

+22 -17
.env.example
···
# See README for setup instructions
WHISPER_SERVICE_URL=http://localhost:8000
-
# LLM API Configuration (Required for VTT cleaning)
# Configure your LLM service endpoint and credentials
-
LLM_API_KEY=your_api_key_here
-
LLM_API_BASE_URL=https://api.openai.com/v1
-
LLM_MODEL=gpt-4o-mini
# WebAuthn/Passkey Configuration (Production Only)
# In development, these default to localhost values
···
# Must match the domain where your app is hosted
# RP_ID=thistle.app
-
# Origin - full URL of your app
# Must match exactly where users access your app
-
# ORIGIN=https://thistle.app
-
# Polar.sh payment stuff
# Get your access token from https://polar.sh/settings (or sandbox.polar.sh for testing)
-
POLAR_ACCESS_TOKEN=XXX
# Get product ID from your Polar dashboard (create a product first)
-
POLAR_PRODUCT_ID=3f1ab9f9-d573-49d4-ac0a-a78bfb06c347
# Redirect URL after successful checkout (use {CHECKOUT_ID} placeholder)
POLAR_SUCCESS_URL=http://localhost:3000/checkout?checkout_id={CHECKOUT_ID}
# Webhook secret for verifying Polar webhook signatures (get from Polar dashboard)
-
POLAR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
-
-
# Environment (set to 'production' in production)
-
NODE_ENV=development
-
# Email Configuration (MailChannels)
# DKIM private key for email authentication (required for sending emails)
# Generate: openssl genrsa -out dkim-private.pem 2048
# Then add TXT record: mailchannels._domainkey.yourdomain.com
-
DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
-
DKIM_DOMAIN=thistle.app
-
SMTP_FROM_EMAIL=noreply@thistle.app
SMTP_FROM_NAME=Thistle
···
# See README for setup instructions
WHISPER_SERVICE_URL=http://localhost:8000
+
# LLM API Configuration (REQUIRED for VTT cleaning)
# Configure your LLM service endpoint and credentials
+
LLM_API_KEY=paste_your_api_key_here
+
LLM_API_BASE_URL=https://openrouter.ai/api/v1
+
LLM_MODEL=moonshotai/kimi-k2-0905
# WebAuthn/Passkey Configuration (Production Only)
# In development, these default to localhost values
···
# Must match the domain where your app is hosted
# RP_ID=thistle.app
+
# Origin - full URL of your app (RECOMMENDED - used for email links)
# Must match exactly where users access your app
+
# In production, set this to your public URL
+
ORIGIN=http://localhost:3000
+
# Polar.sh Payment Configuration (REQUIRED)
+
# Get your organization ID from https://polar.sh/settings
+
POLAR_ORGANIZATION_ID=paste_your_org_id_here
# Get your access token from https://polar.sh/settings (or sandbox.polar.sh for testing)
+
POLAR_ACCESS_TOKEN=paste_your_polar_token_here
# Get product ID from your Polar dashboard (create a product first)
+
POLAR_PRODUCT_ID=paste_your_product_id_here
# Redirect URL after successful checkout (use {CHECKOUT_ID} placeholder)
POLAR_SUCCESS_URL=http://localhost:3000/checkout?checkout_id={CHECKOUT_ID}
# Webhook secret for verifying Polar webhook signatures (get from Polar dashboard)
+
POLAR_WEBHOOK_SECRET=paste_your_webhook_secret_here
+
# Email Configuration (REQUIRED - MailChannels)
+
# API key from MailChannels dashboard
+
MAILCHANNELS_API_KEY=paste_your_mailchannels_api_key_here
# DKIM private key for email authentication (required for sending emails)
# Generate: openssl genrsa -out dkim-private.pem 2048
# Then add TXT record: mailchannels._domainkey.yourdomain.com
+
DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nPASTE_YOUR_DKIM_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
+
DKIM_DOMAIN=yourdomain.com
+
SMTP_FROM_EMAIL=noreply@yourdomain.com
SMTP_FROM_NAME=Thistle
+
+
# Environment (set to 'production' in production)
+
NODE_ENV=development
+117
CRUSH.md
···
**IMPORTANT**: Do NOT commit changes until the user explicitly asks you to commit. Always wait for user verification that changes are working correctly before making commits.
## Project Info
- Name: Thistle
···
**Configuration:**
Set `WHISPER_SERVICE_URL` in `.env` (default: `http://localhost:8000`)
## Future Additions
···
**IMPORTANT**: Do NOT commit changes until the user explicitly asks you to commit. Always wait for user verification that changes are working correctly before making commits.
+
## Environment Variables
+
+
**CRITICAL**: Always use `process.env.ORIGIN` for generating URLs in emails and links, NOT hardcoded domains.
+
+
- `ORIGIN` - The public URL of the application (e.g., `https://thistle.app` or `http://localhost:3000`)
+
- Used for: Email verification links, password reset links, any user-facing URLs
+
- Default: `http://localhost:3000` (development only)
+
+
**Never hardcode domain names** like `https://thistle.app` in code - always use `process.env.ORIGIN`.
+
## Project Info
- Name: Thistle
···
**Configuration:**
Set `WHISPER_SERVICE_URL` in `.env` (default: `http://localhost:8000`)
+
+
## Issue Tracking
+
+
This project uses [Tangled](https://tangled.org) for issue tracking via the `tangled-cli` tool.
+
+
**Installation:**
+
```bash
+
cargo install --git https://tangled.org/vitorpy.com/tangled-cli
+
```
+
+
**Authentication:**
+
```bash
+
tangled-cli auth login
+
```
+
+
**Creating issues:**
+
```bash
+
tangled-cli issue create --repo "thistle" --title "Issue title" --body "Issue description"
+
+
# With labels (if created in the repo):
+
tangled-cli issue create --repo "thistle" --title "Issue title" --label "bug" --label "priority:high" --body "Issue description"
+
```
+
+
**Listing issues:**
+
```bash
+
# List all open issues
+
tangled-cli issue list --repo "thistle"
+
+
# List with specific state
+
tangled-cli issue list --repo "thistle" --state open
+
tangled-cli issue list --repo "thistle" --state closed
+
+
# List by label
+
tangled-cli issue list --repo "thistle" --label "priority: low"
+
tangled-cli issue list --repo "thistle" --label "bug"
+
+
# List by author
+
tangled-cli issue list --repo "thistle" --author "username"
+
+
# JSON output format
+
tangled-cli issue list --repo "thistle" --format json
+
```
+
+
**Showing issue details:**
+
```bash
+
# Show specific issue by ID
+
tangled-cli issue show <issue-id>
+
+
# Show with comments
+
tangled-cli issue show <issue-id> --comments
+
+
# JSON format
+
tangled-cli issue show <issue-id> --json
+
```
+
+
**Commenting on issues:**
+
```bash
+
tangled-cli issue comment <issue-id> --body "Your comment here"
+
```
+
+
**Editing issues:**
+
```bash
+
# Update title
+
tangled-cli issue edit <issue-id> --title "New title"
+
+
# Update body
+
tangled-cli issue edit <issue-id> --body "New description"
+
+
# Close an issue
+
tangled-cli issue edit <issue-id> --state closed
+
+
# Reopen an issue
+
tangled-cli issue edit <issue-id> --state open
+
```
+
+
**Repository commands:**
+
```bash
+
# List your repositories
+
tangled-cli repo list
+
+
# Show repository details
+
tangled-cli repo info thistle
+
+
# Create a new repository
+
tangled-cli repo create --name "repo-name" --description "Description"
+
```
+
+
**Viewing issues by priority:**
+
+
The thistle repo uses priority labels:
+
- `priority: high` - Critical issues that need immediate attention
+
- `priority: medium` - Important issues to address soon
+
- `priority: low` - Nice-to-have improvements
+
+
```bash
+
# View all low priority issues
+
tangled-cli issue list --repo "thistle" --label "priority: low" --state open
+
+
# View all high priority issues
+
tangled-cli issue list --repo "thistle" --label "priority: high" --state open
+
```
+
+
**Note:** The repo name for this project is `thistle` (resolves to `dunkirk.sh/thistle` in Tangled). Labels are supported but need to be created in the repository first.
+
+
**Known Issues:**
+
- The CLI may have decoding issues with some API responses (missing `createdAt` field). If `tangled-cli issue list` fails, you can access issues via the web interface at https://tangled.org/dunkirk.sh/thistle
+
- For complex filtering or browsing, the web UI may be more reliable than the CLI
## Future Additions
-399
index.html
···
-
<!-- vim: setlocal noexpandtab nowrap: -->
-
<!doctype html>
-
<html lang="en">
-
-
<head>
-
<meta charset="UTF-8" />
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
-
<title>C:\KIERANK.EXE - kierank.hackclub.app</title>
-
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
-
<style>
-
@import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap");
-
-
body {
-
background: #008080;
-
font-family: "Courier Prime", "Courier New", monospace;
-
color: #000000;
-
margin: 0;
-
padding: 20px;
-
line-height: 1.4;
-
font-size: 14px;
-
}
-
-
.hidden {
-
display: none !important;
-
}
-
-
.terminal {
-
background: #ffffff;
-
border: 2px solid #808080;
-
box-shadow:
-
inset -2px -2px #c0c0c0,
-
inset 2px 2px #404040,
-
4px 4px 0px #000000;
-
max-width: 750px;
-
margin: 0 auto;
-
padding: 0;
-
}
-
-
.title-bar {
-
background: linear-gradient(90deg, #000080, #1084d0);
-
color: #ffffff;
-
padding: 4px 8px;
-
font-weight: bold;
-
border-bottom: 1px solid #808080;
-
font-size: 12px;
-
display: flex;
-
justify-content: space-between;
-
align-items: center;
-
}
-
-
.title-buttons {
-
display: flex;
-
gap: 2px;
-
}
-
-
.title-btn {
-
width: 16px;
-
height: 14px;
-
background: #c0c0c0;
-
border: 1px outset #ffffff;
-
font-size: 10px;
-
line-height: 12px;
-
text-align: center;
-
cursor: pointer;
-
}
-
-
.content {
-
padding: 15px;
-
background: #ffffff;
-
}
-
-
.prompt {
-
color: #000000;
-
margin: 0 0 10px 0;
-
}
-
-
.cursor {
-
background: #000000;
-
color: #ffffff;
-
animation: blink 1s infinite;
-
}
-
-
@keyframes blink {
-
-
0%,
-
50% {
-
opacity: 1;
-
}
-
-
51%,
-
100% {
-
opacity: 0;
-
}
-
}
-
-
/* Markdown rendered content styles */
-
#readme-content h3 {
-
color: #000080;
-
font-size: 20px;
-
margin: 15px 0 10px 0;
-
border-bottom: 2px solid #c0c0c0;
-
padding-bottom: 5px;
-
}
-
-
#readme-content h4 {
-
color: #000000;
-
font-size: 14px;
-
margin: 20px 0 8px 0;
-
text-transform: uppercase;
-
background: #c0c0c0;
-
padding: 4px 8px;
-
display: inline-block;
-
}
-
-
#readme-content p {
-
margin: 8px 0;
-
}
-
-
#readme-content ul {
-
margin: 8px 0;
-
padding-left: 20px;
-
list-style: none;
-
}
-
-
#readme-content ul li {
-
margin: 6px 0;
-
position: relative;
-
}
-
-
#readme-content ul li::before {
-
content: "► ";
-
color: #000080;
-
}
-
-
#readme-content a {
-
color: #000080;
-
text-decoration: underline;
-
}
-
-
#readme-content a:hover {
-
color: #0000ff;
-
background: #ffffcc;
-
}
-
-
#readme-content code {
-
background: #000080;
-
color: #ffffff;
-
padding: 1px 4px;
-
font-family: "Courier Prime", "Courier New", monospace;
-
}
-
-
#readme-content pre {
-
background: #000080;
-
color: #cbcbcb;
-
padding: 12px;
-
border: 2px inset #404040;
-
overflow-x: auto;
-
font-size: 12px;
-
line-height: 1.3;
-
}
-
-
#readme-content pre code {
-
background: transparent;
-
color: inherit;
-
padding: 0;
-
}
-
-
#readme-content strong {
-
color: #333333;
-
}
-
-
#readme-content em {
-
color: #808080;
-
font-style: italic;
-
}
-
-
.status-bar {
-
background: #c0c0c0;
-
color: #000000;
-
padding: 3px 8px;
-
border-top: 2px groove #ffffff;
-
font-size: 11px;
-
display: flex;
-
justify-content: space-between;
-
}
-
-
.separator {
-
color: #808080;
-
margin: 15px 0;
-
text-align: center;
-
}
-
-
.loading {
-
text-align: center;
-
padding: 20px;
-
color: #808080;
-
}
-
-
.loading::after {
-
content: "";
-
animation: dots 1.5s steps(4, end) infinite;
-
}
-
-
@keyframes dots {
-
-
0%,
-
20% {
-
content: "";
-
}
-
-
40% {
-
content: ".";
-
}
-
-
60% {
-
content: "..";
-
}
-
-
80%,
-
100% {
-
content: "...";
-
}
-
}
-
-
.error-box {
-
background: #ff0000;
-
color: #ffffff;
-
padding: 10px;
-
border: 2px inset #800000;
-
margin: 10px 0;
-
}
-
-
.ascii-header {
-
color: #000080;
-
font-size: 10px;
-
line-height: 1.1;
-
white-space: pre;
-
margin: 10px 0 15px 0;
-
text-align: center;
-
}
-
</style>
-
</head>
-
-
<body>
-
<div class="terminal">
-
<div class="title-bar">
-
<span>C:\KIERANK.EXE - kierank.hackclub.app</span>
-
<div class="title-buttons">
-
<div class="title-btn">_</div>
-
<div class="title-btn">□</div>
-
<div class="title-btn">×</div>
-
</div>
-
</div>
-
-
<div class="content">
-
<p class="prompt">
-
C:\KIERANK> README.EXE<span class="cursor"> </span>
-
</p>
-
-
<!-- biome-ignore format: ascii art -->
-
<pre class="ascii-header">
-
██╗ ██╗██╗███████╗██████╗ █████╗ ███╗ ██╗
-
██║ ██║██║██╔════╝██╔══██╗██╔══██╗████╗ ██║
-
█████╔╝██║█████╗ ██████╔╝███████║██╔██╗ ██║
-
██╔═██╗██║██╔══╝ ██╔══██╗██╔══██║██║╚██╗██║
-
██║ ██║██║███████╗██║ ██║██║ ██║██║ ╚████║
-
╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝
-
</pre>
-
-
<div class="separator">
-
════════════════════════════════════════════════════
-
</div>
-
-
<div id="readme-content">
-
<div class="loading" style="display: none">Loading README from GitHub</div>
-
-
<noscript>
-
<div class="error-box">
-
JavaScript is disabled so not rendering markdown.
-
</div>
-
<p>
-
View the README on GitHub:
-
<a href="https://github.com/taciturnaxolotl/taciturnaxolotl/blob/main/README.md">
-
taciturnaxolotl/README.md
-
</a>
-
</p>
-
</noscript>
-
</div>
-
-
<div class="separator">
-
════════════════════════════════════════════════════
-
</div>
-
-
<p style="font-size: 11px; color: #808080; text-align: center">
-
Hosted on <a href="https://hackclub.com/nest/">Hack Club Nest</a> |
-
<a href="https://github.com/taciturnaxolotl">GitHub</a> |
-
<a href="https://dunkirk.sh">dunkirk.sh</a>
-
</p>
-
</div>
-
-
<div class="status-bar">
-
<span id="status">Fetching README...</span>
-
<span id="time"></span>
-
</div>
-
</div>
-
-
<script>
-
const README_URL =
-
"https://raw.githubusercontent.com/taciturnaxolotl/taciturnaxolotl/refs/heads/main/README.md"
-
-
async function loadReadme() {
-
const contentDiv = document.getElementById("readme-content");
-
const statusSpan = document.getElementById("status");
-
const loadingEl = contentDiv.querySelector(".loading");
-
-
// Show loading
-
if (loadingEl) loadingEl.classList.remove("hidden");
-
-
-
try {
-
const response = await fetch(README_URL);
-
if (!response.ok) {
-
throw new Error(`HTTP ${response.status}`);
-
}
-
-
const markdown = await response.text();
-
-
// Configure marked for GFM
-
marked.setOptions({
-
gfm: true,
-
breaks: true,
-
});
-
-
contentDiv.innerHTML = marked.parse(markdown);
-
-
// Strip emojis from headers
-
contentDiv.querySelectorAll("h3, h4").forEach((header) => {
-
const text = header.textContent;
-
-
// Regex targets:
-
// - \p{Extended_Pictographic}: most modern emoji/pictographs
-
// - \p{Emoji_Presentation}: characters default-rendered as emoji
-
// - Variation Selector-16 (U+FE0F): emoji presentation modifier
-
// - Common symbol blocks that often carry emoji-like glyphs
-
const emojiRegex =
-
/[\p{Extended_Pictographic}\p{Emoji_Presentation}\uFE0F]|[\u2600-\u26FF\u2700-\u27BF]/gu;
-
-
// Strip emojis
-
let cleaned = text.replace(emojiRegex, "");
-
-
// Collapse multiple whitespace into single spaces
-
cleaned = cleaned.replace(/\s+/g, " ");
-
-
// Remove spaces before punctuation (e.g., "Hello !" -> "Hello!")
-
cleaned = cleaned.replace(/\s+([!?,.;:])/g, "$1");
-
-
// Trim leading/trailing whitespace
-
cleaned = cleaned.trim();
-
-
// Replace header content safely
-
header.textContent = cleaned;
-
});
-
-
statusSpan.textContent = "README loaded successfully";
-
} catch (error) {
-
contentDiv.innerHTML = `
-
<div class="error-box">
-
ERROR: Failed to load README<br>
-
${error.message}
-
</div>
-
<p>Try refreshing the page or visit
-
<a href="https://github.com/taciturnaxolotl">github.com/taciturnaxolotl</a> directly.</p>
-
`;
-
statusSpan.textContent = "Error loading README";
-
}
-
}
-
-
function updateTime() {
-
const now = new Date();
-
const timeStr = now.toLocaleTimeString("en-US", {
-
hour: "2-digit",
-
minute: "2-digit",
-
hour12: true,
-
});
-
document.getElementById("time").textContent = timeStr;
-
}
-
-
// Initialize
-
updateTime();
-
setInterval(updateTime, 1000);
-
loadReadme();
-
</script>
-
</body>
-
-
</html>
-
-
</html>
-
-
</html>
···
+28 -28
package.json
···
{
-
"name": "thistle",
-
"module": "src/index.ts",
-
"type": "module",
-
"private": true,
-
"scripts": {
-
"dev": "bun run src/index.ts --hot",
-
"clean": "rm -rf transcripts uploads thistle.db",
-
"test": "bun test",
-
"test:integration": "bun test src/index.test.ts",
-
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
-
},
-
"devDependencies": {
-
"@biomejs/biome": "^2.3.2",
-
"@simplewebauthn/types": "^12.0.0",
-
"@types/bun": "latest"
-
},
-
"peerDependencies": {
-
"typescript": "^5"
-
},
-
"dependencies": {
-
"@polar-sh/sdk": "^0.41.5",
-
"@simplewebauthn/browser": "^13.2.2",
-
"@simplewebauthn/server": "^13.2.2",
-
"eventsource-client": "^1.2.0",
-
"lit": "^3.3.1",
-
"nanoid": "^5.1.6",
-
"ua-parser-js": "^2.0.6"
-
}
}
···
{
+
"name": "thistle",
+
"module": "src/index.ts",
+
"type": "module",
+
"private": true,
+
"scripts": {
+
"dev": "bun run src/index.ts --hot",
+
"clean": "rm -rf transcripts uploads thistle.db",
+
"test": "NODE_ENV=test bun test",
+
"test:integration": "NODE_ENV=test bun test src/index.test.ts",
+
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
+
},
+
"devDependencies": {
+
"@biomejs/biome": "^2.3.2",
+
"@simplewebauthn/types": "^12.0.0",
+
"@types/bun": "latest"
+
},
+
"peerDependencies": {
+
"typescript": "^5"
+
},
+
"dependencies": {
+
"@polar-sh/sdk": "^0.41.5",
+
"@simplewebauthn/browser": "^13.2.2",
+
"@simplewebauthn/server": "^13.2.2",
+
"eventsource-client": "^1.2.0",
+
"lit": "^3.3.1",
+
"nanoid": "^5.1.6",
+
"ua-parser-js": "^2.0.6"
+
}
}
+19 -1
public/favicon/site.webmanifest
···
-
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
···
+
{
+
"name": "",
+
"short_name": "",
+
"icons": [
+
{
+
"src": "/android-chrome-192x192.png",
+
"sizes": "192x192",
+
"type": "image/png"
+
},
+
{
+
"src": "/android-chrome-512x512.png",
+
"sizes": "512x512",
+
"type": "image/png"
+
}
+
],
+
"theme_color": "#ffffff",
+
"background_color": "#ffffff",
+
"display": "standalone"
+
}
+17
scripts/clear-rate-limits.ts
···
···
+
#!/usr/bin/env bun
+
+
import db from "../src/db/schema";
+
+
console.log("🧹 Clearing all rate limit attempts...");
+
+
const result = db.run("DELETE FROM rate_limit_attempts");
+
+
const deletedCount = result.changes;
+
+
if (deletedCount === 0) {
+
console.log("ℹ️ No rate limit attempts to clear");
+
} else {
+
console.log(
+
`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? "" : "s"}`,
+
);
+
}
+39
scripts/remove-from-classes.ts
···
···
+
#!/usr/bin/env bun
+
+
import db from "../src/db/schema";
+
+
const email = process.argv[2];
+
+
if (!email) {
+
console.error("Usage: bun scripts/remove-from-classes.ts <email>");
+
console.error(" Removes a user from all their enrolled classes");
+
process.exit(1);
+
}
+
+
const user = db
+
.query<{ id: number; email: string }, [string]>(
+
"SELECT id, email FROM users WHERE email = ?",
+
)
+
.get(email);
+
+
if (!user) {
+
console.error(`User with email ${email} not found`);
+
process.exit(1);
+
}
+
+
// Get current enrollments
+
const enrollments = db
+
.query<{ class_id: string }, [number]>(
+
"SELECT class_id FROM class_members WHERE user_id = ?",
+
)
+
.all(user.id);
+
+
if (enrollments.length === 0) {
+
console.log(`User ${email} is not enrolled in any classes`);
+
process.exit(0);
+
}
+
+
// Remove from all classes
+
db.run("DELETE FROM class_members WHERE user_id = ?", [user.id]);
+
+
console.log(`✅ Successfully removed ${email} from ${enrollments.length} class(es)`);
+1 -1
scripts/send-test-emails.ts
···
import { sendEmail } from "../src/lib/email";
import {
-
verifyEmailTemplate,
passwordResetTemplate,
transcriptionCompleteTemplate,
} from "../src/lib/email-templates";
const targetEmail = process.argv[2];
···
import { sendEmail } from "../src/lib/email";
import {
passwordResetTemplate,
transcriptionCompleteTemplate,
+
verifyEmailTemplate,
} from "../src/lib/email-templates";
const targetEmail = process.argv[2];
+227 -21
src/components/admin-classes.ts
···
year: number;
archived: boolean;
created_at: number;
}
interface WaitlistEntry {
···
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
@state() showModal = false;
@state() meetingTimes: MeetingTime[] = [];
@state() editingClass = {
courseCode: "",
courseName: "",
···
override async connectedCallback() {
super.connectedCallback();
-
// Check for subtab query parameter
const params = new URLSearchParams(window.location.search);
const subtab = params.get("subtab");
···
// Set default subtab in URL if on classes tab
this.setActiveTab(this.activeTab);
}
-
await this.loadData();
}
···
const classesData = await classesRes.json();
const waitlistData = await waitlistRes.json();
-
this.classes = classesData.classes || [];
this.waitlist = waitlistData.waitlist || [];
} catch {
this.error = "Failed to load data. Please try again.";
···
private async handleToggleArchive(classId: string) {
try {
// Find the class to toggle its archived state
-
const classToToggle = this.classes.find(c => c.id === classId);
if (!classToToggle) return;
const response = await fetch(`/api/classes/${classId}/archive`, {
···
}
// Update local state instead of reloading
-
this.classes = this.classes.map(c =>
-
c.id === classId ? { ...c, archived: !c.archived } : c
);
} catch {
this.error = "Failed to update class. Please try again.";
···
this.showModal = true;
}
private getFilteredClasses() {
if (!this.searchTerm) return this.classes;
···
}
${this.showModal ? this.renderApprovalModal() : ""}
`;
}
···
<div class="classes-grid">
${filteredClasses.map(
(cls) => html`
-
<div class="class-card ${cls.archived ? "archived" : ""}">
<div class="class-header">
<div class="class-info">
<div class="course-code">${cls.course_code}</div>
···
<div class="class-meta">
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
</div>
</div>
-
<div class="actions">
-
<button
-
class="btn-archive"
-
@click=${() => this.handleToggleArchive(cls.id)}
-
>
-
${cls.archived ? "Unarchive" : "Archive"}
-
</button>
-
<button
-
class="btn-delete"
-
@click=${() => this.handleDeleteClick(cls.id, "class")}
-
>
-
${this.getDeleteButtonText(cls.id, "class")}
-
</button>
-
</div>
</div>
</div>
`,
···
`;
}
private renderWaitlist() {
return html`
${
···
this.meetingTimes = e.detail;
}
private handleClassFieldInput(field: string, e: Event) {
const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
this.editingClass = { ...this.editingClass, [field]: value };
···
this.showModal = false;
this.approvingEntry = null;
this.meetingTimes = [];
this.editingClass = {
courseCode: "",
courseName: "",
···
semester: this.editingClass.semester,
year: this.editingClass.year,
meeting_times: labels,
}),
});
···
.value=${this.meetingTimes}
@change=${this.handleMeetingTimesChange}
></meeting-time-picker>
</div>
</div>
···
year: number;
archived: boolean;
created_at: number;
+
student_count?: number;
+
transcript_count?: number;
}
interface WaitlistEntry {
···
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
@state() showModal = false;
+
@state() showClassSettingsModal = false;
+
@state() editingClassId: string | null = null;
+
@state() editingClassInfo: Class | null = null;
+
@state() editingClassSections: { id: string; section_number: string }[] = [];
+
@state() newSectionNumber = "";
@state() meetingTimes: MeetingTime[] = [];
+
@state() sections: string[] = [];
@state() editingClass = {
courseCode: "",
courseName: "",
···
override async connectedCallback() {
super.connectedCallback();
+
// Check for subtab query parameter
const params = new URLSearchParams(window.location.search);
const subtab = params.get("subtab");
···
// Set default subtab in URL if on classes tab
this.setActiveTab(this.activeTab);
}
+
await this.loadData();
}
···
const classesData = await classesRes.json();
const waitlistData = await waitlistRes.json();
+
// Flatten grouped classes into array
+
const groupedClasses = classesData.classes || {};
+
this.classes = Object.values(groupedClasses).flat();
this.waitlist = waitlistData.waitlist || [];
} catch {
this.error = "Failed to load data. Please try again.";
···
private async handleToggleArchive(classId: string) {
try {
// Find the class to toggle its archived state
+
const classToToggle = this.classes.find((c) => c.id === classId);
if (!classToToggle) return;
const response = await fetch(`/api/classes/${classId}/archive`, {
···
}
// Update local state instead of reloading
+
this.classes = this.classes.map((c) =>
+
c.id === classId ? { ...c, archived: !c.archived } : c,
);
} catch {
this.error = "Failed to update class. Please try again.";
···
this.showModal = true;
}
+
private async handleEditSections(classId: string) {
+
try {
+
const response = await fetch(`/api/classes/${classId}`);
+
if (!response.ok) throw new Error("Failed to load class");
+
+
const data = await response.json();
+
this.editingClassId = classId;
+
this.editingClassInfo = data.class;
+
this.editingClassSections = data.sections || [];
+
this.newSectionNumber = "";
+
this.showClassSettingsModal = true;
+
} catch {
+
this.error = "Failed to load class details";
+
}
+
}
+
+
private async handleAddSection() {
+
if (!this.newSectionNumber.trim() || !this.editingClassId) return;
+
+
try {
+
const response = await fetch(`/api/classes/${this.editingClassId}/sections`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ section_number: this.newSectionNumber.trim() }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to add section";
+
return;
+
}
+
+
const newSection = await response.json();
+
this.editingClassSections = [...this.editingClassSections, newSection];
+
this.newSectionNumber = "";
+
} catch {
+
this.error = "Failed to add section";
+
}
+
}
+
+
private async handleDeleteSection(sectionId: string) {
+
if (!this.editingClassId) return;
+
+
try {
+
const response = await fetch(`/api/classes/${this.editingClassId}/sections/${sectionId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to delete section";
+
return;
+
}
+
+
this.editingClassSections = this.editingClassSections.filter(s => s.id !== sectionId);
+
} catch {
+
this.error = "Failed to delete section";
+
}
+
}
+
+
private handleCloseSectionsModal() {
+
this.showClassSettingsModal = false;
+
this.editingClassId = null;
+
this.editingClassInfo = null;
+
this.editingClassSections = [];
+
this.newSectionNumber = "";
+
this.loadData();
+
}
+
+
private getFilteredClasses() {
if (!this.searchTerm) return this.classes;
···
}
${this.showModal ? this.renderApprovalModal() : ""}
+
${this.showClassSettingsModal ? this.renderClassSettingsModal() : ""}
`;
}
···
<div class="classes-grid">
${filteredClasses.map(
(cls) => html`
+
<div
+
class="class-card ${cls.archived ? "archived" : ""}"
+
@click=${() => this.handleEditSections(cls.id)}
+
style="cursor: pointer;"
+
>
<div class="class-header">
<div class="class-info">
<div class="course-code">${cls.course_code}</div>
···
<div class="class-meta">
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
+
<span>👥 ${cls.student_count || 0} students</span>
+
<span>📄 ${cls.transcript_count || 0} transcripts</span>
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
</div>
</div>
</div>
</div>
`,
···
`;
}
+
private renderClassSettingsModal() {
+
if (!this.showClassSettingsModal || !this.editingClassInfo) return html``;
+
+
return html`
+
<div class="modal-overlay" @click=${this.handleCloseSectionsModal}>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()} style="max-width: 48rem;">
+
<div class="modal-header">
+
<h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2>
+
<button class="close-btn" @click=${this.handleCloseSectionsModal} type="button">×</button>
+
</div>
+
+
<div class="tabs" style="margin-bottom: 1.5rem;">
+
<div style="display: flex; gap: 0.5rem; border-bottom: 2px solid var(--secondary);">
+
<button
+
style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: none; border-bottom: 2px solid var(--primary); font-weight: 600; cursor: pointer; margin-bottom: -2px;"
+
>
+
Sections
+
</button>
+
</div>
+
</div>
+
+
<!-- Sections Tab -->
+
<div style="margin-bottom: 1.5rem;">
+
<h3 style="margin-bottom: 1rem; color: var(--text);">Manage Sections</h3>
+
+
<div style="display: flex; gap: 0.75rem; margin-bottom: 1rem;">
+
<input
+
type="text"
+
placeholder="Section number (e.g., 01, 02, A, B)"
+
.value=${this.newSectionNumber}
+
@input=${(e: Event) => {
+
this.newSectionNumber = (e.target as HTMLInputElement).value;
+
}}
+
@keypress=${(e: KeyboardEvent) => {
+
if (e.key === "Enter") {
+
e.preventDefault();
+
this.handleAddSection();
+
}
+
}}
+
style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);"
+
/>
+
<button
+
@click=${this.handleAddSection}
+
?disabled=${!this.newSectionNumber.trim()}
+
style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer; white-space: nowrap;"
+
>
+
Add Section
+
</button>
+
</div>
+
+
${
+
this.editingClassSections.length === 0
+
? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>`
+
: html`
+
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
+
${this.editingClassSections.map(
+
(section) => html`
+
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: color-mix(in srgb, var(--secondary) 30%, transparent); border-radius: 6px;">
+
<span style="font-weight: 500;">Section ${section.section_number}</span>
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleDeleteSection(section.id);
+
}}
+
style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;"
+
>
+
Delete
+
</button>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
</div>
+
+
<!-- Actions -->
+
<div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);">
+
<div style="display: flex; gap: 0.75rem;">
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleToggleArchive(this.editingClassId!);
+
this.handleCloseSectionsModal();
+
}}
+
style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
${this.editingClassInfo.archived ? "Unarchive" : "Archive"} Class
+
</button>
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleDeleteClick(this.editingClassId!, "class");
+
}}
+
style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
${this.getDeleteButtonText(this.editingClassId!, "class")}
+
</button>
+
</div>
+
<button
+
@click=${this.handleCloseSectionsModal}
+
style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
Done
+
</button>
+
</div>
+
+
${this.error ? html`<div style="color: red; margin-top: 1rem; padding: 0.75rem; background: color-mix(in srgb, red 10%, transparent); border-radius: 6px;">${this.error}</div>` : ""}
+
</div>
+
</div>
+
`;
+
}
+
private renderWaitlist() {
return html`
${
···
this.meetingTimes = e.detail;
}
+
private handleSectionsChange(e: Event) {
+
const value = (e.target as HTMLInputElement).value;
+
this.sections = value
+
.split(",")
+
.map((s) => s.trim())
+
.filter((s) => s);
+
}
+
private handleClassFieldInput(field: string, e: Event) {
const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
this.editingClass = { ...this.editingClass, [field]: value };
···
this.showModal = false;
this.approvingEntry = null;
this.meetingTimes = [];
+
this.sections = [];
this.editingClass = {
courseCode: "",
courseName: "",
···
semester: this.editingClass.semester,
year: this.editingClass.year,
meeting_times: labels,
+
sections: this.sections.length > 0 ? this.sections : undefined,
}),
});
···
.value=${this.meetingTimes}
@change=${this.handleMeetingTimesChange}
></meeting-time-picker>
+
</div>
+
<div class="form-group form-group-full">
+
<label>Sections (optional)</label>
+
<input
+
type="text"
+
placeholder="e.g., 01, 02, 03 or A, B, C"
+
.value=${this.sections.join(", ")}
+
@input=${this.handleSectionsChange}
+
/>
+
<div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div>
</div>
</div>
+19 -10
src/components/admin-pending-recordings.ts
···
this.isLoading = true;
this.error = null;
-
try {
-
// Get all classes with their transcriptions
-
const response = await fetch("/api/classes");
-
if (!response.ok) {
-
const data = await response.json();
-
throw new Error(data.error || "Failed to load classes");
-
}
const data = await response.json();
const classesGrouped = data.classes || {};
···
this.recordings = pendingRecordings;
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again.";
} finally {
this.isLoading = false;
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again.";
}
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again.";
}
}
···
this.isLoading = true;
this.error = null;
+
try {
+
// Get all classes with their transcriptions
+
const response = await fetch("/api/classes");
+
if (!response.ok) {
+
const data = await response.json();
+
throw new Error(data.error || "Failed to load classes");
+
}
const data = await response.json();
const classesGrouped = data.classes || {};
···
this.recordings = pendingRecordings;
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load pending recordings. Please try again.";
} finally {
this.isLoading = false;
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to approve recording. Please try again.";
}
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete recording. Please try again.";
}
}
+10 -3
src/components/admin-transcriptions.ts
···
throw new Error(data.error || "Failed to load transcriptions");
}
-
this.transcriptions = await response.json();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadTranscriptions();
this.dispatchEvent(new CustomEvent("transcription-deleted"));
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again.";
}
}
···
throw new Error(data.error || "Failed to load transcriptions");
}
+
const result = await response.json();
+
this.transcriptions = result.data || result;
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load transcriptions. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadTranscriptions();
this.dispatchEvent(new CustomEvent("transcription-deleted"));
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete transcription. Please try again.";
}
}
+66 -31
src/components/admin-users.ts
···
throw new Error(data.error || "Failed to load users");
}
-
this.users = await response.json();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load users. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadUsers();
}
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to update user role";
select.value = oldRole;
}
}
···
}
// Remove user from local array instead of reloading
-
this.users = this.users.filter(u => u.id !== userId);
this.dispatchEvent(new CustomEvent("user-deleted"));
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete user. Please try again.";
}
}
-
private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {
event.stopPropagation();
// If this is a different item or timeout expired, reset
···
this.deleteState = null;
}, 1000);
-
this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout };
}
-
private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) {
this.revokingSubscriptions.add(userId);
this.requestUpdate();
this.error = null;
···
await this.loadUsers();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to revoke subscription";
this.revokingSubscriptions.delete(userId);
}
}
···
if (userId === 0) {
return;
}
-
// Don't open modal if clicking on delete button, revoke button, sync button, or role select
if (
(event.target as HTMLElement).closest(".delete-btn") ||
···
private get filteredUsers() {
const query = this.searchQuery.toLowerCase();
-
// Filter users based on search query
let filtered = this.users.filter(
(u) =>
u.email.toLowerCase().includes(query) ||
u.name?.toLowerCase().includes(query),
);
-
// Hide ghost user unless specifically searched for
-
if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) {
-
filtered = filtered.filter(u => u.id !== 0);
}
-
return filtered;
}
···
<div class="users-grid">
${filtered.map(
(u) => html`
-
<div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
<div class="card-header">
<div class="user-info">
<img
···
<div class="user-email">${u.email}</div>
</div>
</div>
-
${u.id === 0
-
? html`<span class="system-badge">System</span>`
-
: u.role === "admin"
-
? html`<span class="admin-badge">Admin</span>`
-
: ""
-
}
</div>
<div class="meta-row">
···
<div class="meta-item">
<div class="meta-label">Subscription</div>
<div class="meta-value">
-
${u.subscription_status
-
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
-
: html`<span class="subscription-badge none">None</span>`
-
}
</div>
</div>
<div class="meta-item">
···
</div>
<div class="actions">
-
${u.id === 0
-
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
-
: html`
<select
class="role-select"
.value=${u.role}
···
?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
@click=${(e: Event) => {
if (u.subscription_id) {
-
this.handleRevokeClick(u.id, u.email, u.subscription_id, e);
}
}}
>
···
${this.getDeleteButtonText(u.id, "user")}
</button>
`
-
}
</div>
</div>
`,
···
throw new Error(data.error || "Failed to load users");
}
+
const result = await response.json();
+
this.users = result.data || result;
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load users. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadUsers();
}
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to update user role";
select.value = oldRole;
}
}
···
}
// Remove user from local array instead of reloading
+
this.users = this.users.filter((u) => u.id !== userId);
this.dispatchEvent(new CustomEvent("user-deleted"));
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete user. Please try again.";
}
}
+
private handleRevokeClick(
+
userId: number,
+
email: string,
+
subscriptionId: string,
+
event: Event,
+
) {
event.stopPropagation();
// If this is a different item or timeout expired, reset
···
this.deleteState = null;
}, 1000);
+
this.deleteState = {
+
id: userId,
+
type: "revoke",
+
clicks: newClicks,
+
timeout,
+
};
}
+
private async performRevokeSubscription(
+
userId: number,
+
_email: string,
+
subscriptionId: string,
+
) {
this.revokingSubscriptions.add(userId);
this.requestUpdate();
this.error = null;
···
await this.loadUsers();
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to revoke subscription";
this.revokingSubscriptions.delete(userId);
}
}
···
if (userId === 0) {
return;
}
+
// Don't open modal if clicking on delete button, revoke button, sync button, or role select
if (
(event.target as HTMLElement).closest(".delete-btn") ||
···
private get filteredUsers() {
const query = this.searchQuery.toLowerCase();
+
// Filter users based on search query
let filtered = this.users.filter(
(u) =>
u.email.toLowerCase().includes(query) ||
u.name?.toLowerCase().includes(query),
);
+
// Hide ghost user unless specifically searched for
+
if (
+
!query.includes("deleted") &&
+
!query.includes("ghost") &&
+
!query.includes("system")
+
) {
+
filtered = filtered.filter((u) => u.id !== 0);
}
+
return filtered;
}
···
<div class="users-grid">
${filtered.map(
(u) => html`
+
<div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
<div class="card-header">
<div class="user-info">
<img
···
<div class="user-email">${u.email}</div>
</div>
</div>
+
${
+
u.id === 0
+
? html`<span class="system-badge">System</span>`
+
: u.role === "admin"
+
? html`<span class="admin-badge">Admin</span>`
+
: ""
+
}
</div>
<div class="meta-row">
···
<div class="meta-item">
<div class="meta-label">Subscription</div>
<div class="meta-value">
+
${
+
u.subscription_status
+
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
+
: html`<span class="subscription-badge none">None</span>`
+
}
</div>
</div>
<div class="meta-item">
···
</div>
<div class="actions">
+
${
+
u.id === 0
+
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
+
: html`
<select
class="role-select"
.value=${u.role}
···
?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
@click=${(e: Event) => {
if (u.subscription_id) {
+
this.handleRevokeClick(
+
u.id,
+
u.email,
+
u.subscription_id,
+
e,
+
);
}
}}
>
···
${this.getDeleteButtonText(u.id, "user")}
</button>
`
+
}
</div>
</div>
`,
+10 -10
src/components/auth.ts
···
}
const data = await response.json();
-
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
}
const data = await response.json();
-
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
private startResendTimer(sentAtTimestamp: number) {
// Use provided timestamp
this.codeSentAt = sentAtTimestamp;
-
// Clear existing interval if any
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
}
-
// Update timer based on elapsed time
const updateTimer = () => {
if (this.codeSentAt === null) return;
-
const now = Math.floor(Date.now() / 1000);
const elapsed = now - this.codeSentAt;
-
const remaining = Math.max(0, (5 * 60) - elapsed);
this.resendCodeTimer = remaining;
-
if (remaining <= 0) {
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
···
}
}
};
-
// Update immediately
updateTimer();
-
// Then update every second
this.resendInterval = window.setInterval(updateTimer, 1000);
}
···
private formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
-
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
override disconnectedCallback() {
···
}
const data = await response.json();
+
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
}
const data = await response.json();
+
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
private startResendTimer(sentAtTimestamp: number) {
// Use provided timestamp
this.codeSentAt = sentAtTimestamp;
+
// Clear existing interval if any
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
}
+
// Update timer based on elapsed time
const updateTimer = () => {
if (this.codeSentAt === null) return;
+
const now = Math.floor(Date.now() / 1000);
const elapsed = now - this.codeSentAt;
+
const remaining = Math.max(0, 5 * 60 - elapsed);
this.resendCodeTimer = remaining;
+
if (remaining <= 0) {
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
···
}
}
};
+
// Update immediately
updateTimer();
+
// Then update every second
this.resendInterval = window.setInterval(updateTimer, 1000);
}
···
private formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
override disconnectedCallback() {
+60 -11
src/components/class-registration-modal.ts
···
professor: string;
semester: string;
year: number;
is_enrolled?: boolean;
}
···
@state() error = "";
@state() hasSearched = false;
@state() showWaitlistForm = false;
@state() waitlistData = {
courseCode: "",
courseName: "",
···
this.error = "";
this.hasSearched = false;
this.showWaitlistForm = false;
this.waitlistData = {
courseCode: "",
courseName: "",
···
}
}
-
private async handleJoin(classId: string) {
this.isJoining = true;
this.error = "";
···
const response = await fetch("/api/classes/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ class_id: classId }),
});
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to join class";
return;
}
// Success - notify parent and close
this.dispatchEvent(new CustomEvent("class-joined"));
this.handleClose();
-
} catch {
this.error = "Failed to join class. Please try again.";
-
} finally {
this.isJoining = false;
}
}
···
<div class="results-grid">
${this.results.map(
(cls) => html`
-
<button
-
class="class-card ${cls.is_enrolled ? "enrolled" : ""}"
-
@click=${() => !cls.is_enrolled && this.handleJoin(cls.id)}
-
?disabled=${this.isJoining || cls.is_enrolled}
-
>
<div class="class-header">
<div class="class-info">
<div class="course-code">
···
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
</div>
${
!cls.is_enrolled
···
?disabled=${this.isJoining}
@click=${(e: Event) => {
e.stopPropagation();
-
this.handleJoin(cls.id);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
-
</button>
`,
)}
</div>
···
professor: string;
semester: string;
year: number;
+
sections?: { id: string; section_number: string }[];
is_enrolled?: boolean;
}
···
@state() error = "";
@state() hasSearched = false;
@state() showWaitlistForm = false;
+
@state() selectedSections: Map<string, string> = new Map();
@state() waitlistData = {
courseCode: "",
courseName: "",
···
this.error = "";
this.hasSearched = false;
this.showWaitlistForm = false;
+
this.selectedSections = new Map();
this.waitlistData = {
courseCode: "",
courseName: "",
···
}
}
+
private async handleJoin(
+
classId: string,
+
sections?: { id: string; section_number: string }[],
+
) {
+
// If class has sections, require section selection
+
const selectedSection = this.selectedSections.get(classId);
+
if (sections && sections.length > 0 && !selectedSection) {
+
this.error = "Please select a section";
+
this.requestUpdate();
+
return;
+
}
+
this.isJoining = true;
this.error = "";
···
const response = await fetch("/api/classes/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
class_id: classId,
+
section_id: selectedSection || null,
+
}),
});
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to join class";
+
this.isJoining = false;
+
this.requestUpdate();
return;
}
// Success - notify parent and close
this.dispatchEvent(new CustomEvent("class-joined"));
this.handleClose();
+
} catch (error) {
+
console.error("Failed to join class:", error);
this.error = "Failed to join class. Please try again.";
this.isJoining = false;
+
this.requestUpdate();
}
}
···
<div class="results-grid">
${this.results.map(
(cls) => html`
+
<div class="class-card ${cls.is_enrolled ? "enrolled" : ""}">
<div class="class-header">
<div class="class-info">
<div class="course-code">
···
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
+
${
+
!cls.is_enrolled &&
+
cls.sections &&
+
cls.sections.length > 0
+
? html`
+
<div style="margin-top: 0.75rem;">
+
<label style="font-size: 0.75rem; margin-bottom: 0.25rem;">Select Section *</label>
+
<select
+
style="width: 100%; padding: 0.5rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text);"
+
@change=${(e: Event) => {
+
const sectionId = (
+
e.target as HTMLSelectElement
+
).value;
+
if (sectionId) {
+
this.selectedSections.set(cls.id, sectionId);
+
} else {
+
this.selectedSections.delete(cls.id);
+
}
+
this.error = "";
+
this.requestUpdate();
+
}}
+
>
+
<option value="">Choose a section...</option>
+
${cls.sections.map(
+
(s) =>
+
html`<option value="${s.id}" ?selected=${this.selectedSections.get(cls.id) === s.id}>${s.section_number}</option>`,
+
)}
+
</select>
+
</div>
+
`
+
: ""
+
}
</div>
${
!cls.is_enrolled
···
?disabled=${this.isJoining}
@click=${(e: Event) => {
e.stopPropagation();
+
console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections);
+
this.handleJoin(cls.id, cls.sections);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
+
</div>
`,
)}
</div>
+123 -13
src/components/class-view.ts
···
import { customElement, state } from "lit/decorators.js";
import "./upload-recording-modal.ts";
import "./vtt-viewer.ts";
interface Class {
id: string;
···
id: string;
user_id: number;
meeting_time_id: string | null;
filename: string;
original_filename: string;
status:
···
audioUrl?: string;
}
@customElement("class-view")
export class ClassView extends LitElement {
@state() classId = "";
@state() classInfo: Class | null = null;
@state() meetingTimes: MeetingTime[] = [];
@state() transcriptions: Transcription[] = [];
@state() isLoading = true;
@state() error: string | null = null;
···
const data = await response.json();
this.classInfo = data.class;
this.meetingTimes = data.meetingTimes || [];
this.transcriptions = data.transcriptions || [];
// Load VTT for completed transcriptions
await this.loadVTTForCompleted();
···
}
private get filteredTranscriptions() {
-
if (!this.searchQuery) return this.transcriptions;
-
const query = this.searchQuery.toLowerCase();
-
return this.transcriptions.filter((t) =>
-
t.original_filename.toLowerCase().includes(query),
-
);
}
private formatDate(timestamp: number): string {
···
<div class="course-code">${this.classInfo.course_code}</div>
<h1>${this.classInfo.name}</h1>
<div class="professor">Professor: ${this.classInfo.professor}</div>
-
<div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div>
</div>
</div>
···
: ""
}
-
${!canAccessTranscriptions ? html`
<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Access Recordings</h3>
<p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p>
<a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>
</div>
-
` : html`
<div class="search-upload">
<input
type="text"
class="search-box"
···
</button>
</div>
${
this.filteredTranscriptions.length === 0
? html`
···
<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
</div>
`
-
: html`
${this.filteredTranscriptions.map(
-
(t) => html`
<div class="transcription-card">
<div class="transcription-header">
<div>
···
${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""}
</div>
`,
-
)}
`
-
}
-
`}
</div>
<upload-recording-modal
?open=${this.uploadModalOpen}
.classId=${this.classId}
.meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))}
@close=${this.handleModalClose}
@upload-success=${this.handleUploadSuccess}
></upload-recording-modal>
···
import { customElement, state } from "lit/decorators.js";
import "./upload-recording-modal.ts";
import "./vtt-viewer.ts";
+
import "./pending-recordings-view.ts";
interface Class {
id: string;
···
id: string;
user_id: number;
meeting_time_id: string | null;
+
section_id: string | null;
filename: string;
original_filename: string;
status:
···
audioUrl?: string;
}
+
interface ClassSection {
+
id: string;
+
section_number: string;
+
}
+
@customElement("class-view")
export class ClassView extends LitElement {
@state() classId = "";
@state() classInfo: Class | null = null;
@state() meetingTimes: MeetingTime[] = [];
+
@state() sections: ClassSection[] = [];
+
@state() userSection: string | null = null;
+
@state() selectedSectionFilter: string | null = null;
@state() transcriptions: Transcription[] = [];
@state() isLoading = true;
@state() error: string | null = null;
···
const data = await response.json();
this.classInfo = data.class;
this.meetingTimes = data.meetingTimes || [];
+
this.sections = data.sections || [];
+
this.userSection = data.userSection || null;
this.transcriptions = data.transcriptions || [];
+
+
// Default to user's section for filtering
+
if (this.userSection && !this.selectedSectionFilter) {
+
this.selectedSectionFilter = this.userSection;
+
}
// Load VTT for completed transcriptions
await this.loadVTTForCompleted();
···
}
private get filteredTranscriptions() {
+
let filtered = this.transcriptions;
+
// Filter by selected section (or user's section by default)
+
const sectionFilter = this.selectedSectionFilter || this.userSection;
+
+
// Only filter by section if:
+
// 1. There are sections in the class
+
// 2. User has a section OR has selected one
+
if (this.sections.length > 0 && sectionFilter) {
+
// For admins: show all transcriptions
+
// For users: show their section + transcriptions with no section (legacy/unassigned)
+
if (!this.isAdmin) {
+
filtered = filtered.filter(
+
(t) => t.section_id === sectionFilter || t.section_id === null,
+
);
+
}
+
}
+
+
// Filter by search query
+
if (this.searchQuery) {
+
const query = this.searchQuery.toLowerCase();
+
filtered = filtered.filter((t) =>
+
t.original_filename.toLowerCase().includes(query),
+
);
+
}
+
+
// Exclude pending recordings (they're shown in the voting section)
+
filtered = filtered.filter((t) => t.status !== "pending");
+
+
return filtered;
}
private formatDate(timestamp: number): string {
···
<div class="course-code">${this.classInfo.course_code}</div>
<h1>${this.classInfo.name}</h1>
<div class="professor">Professor: ${this.classInfo.professor}</div>
+
<div class="semester">
+
${this.classInfo.semester} ${this.classInfo.year}
+
${
+
this.userSection
+
? ` • Section ${this.sections.find((s) => s.id === this.userSection)?.section_number || ""}`
+
: ""
+
}
+
</div>
</div>
</div>
···
: ""
}
+
${
+
!canAccessTranscriptions
+
? html`
<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Access Recordings</h3>
<p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p>
<a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>
</div>
+
`
+
: html`
<div class="search-upload">
+
${
+
this.sections.length > 1
+
? html`
+
<select
+
style="padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"
+
@change=${(e: Event) => {
+
this.selectedSectionFilter =
+
(e.target as HTMLSelectElement).value || null;
+
}}
+
.value=${this.selectedSectionFilter || ""}
+
>
+
${this.sections.map(
+
(s) =>
+
html`<option value=${s.id} ?selected=${s.id === this.selectedSectionFilter}>${s.section_number}</option>`,
+
)}
+
</select>
+
`
+
: ""
+
}
<input
type="text"
class="search-box"
···
</button>
</div>
+
<!-- Pending Recordings for Voting -->
+
${
+
this.meetingTimes.map((meeting) => {
+
// Apply section filtering to pending recordings
+
const sectionFilter = this.selectedSectionFilter || this.userSection;
+
+
const pendingCount = this.transcriptions.filter((t) => {
+
if (t.meeting_time_id !== meeting.id || t.status !== "pending") {
+
return false;
+
}
+
+
// Filter by section if applicable
+
if (this.sections.length > 0 && sectionFilter) {
+
// Show recordings from user's section or no section (unassigned)
+
return t.section_id === sectionFilter || t.section_id === null;
+
}
+
+
return true;
+
}).length;
+
+
// Only show if there are pending recordings
+
if (pendingCount === 0) return "";
+
+
return html`
+
<div style="margin-bottom: 2rem;">
+
<pending-recordings-view
+
.classId=${this.classId}
+
.meetingTimeId=${meeting.id}
+
.meetingTimeLabel=${meeting.label}
+
.sectionId=${sectionFilter}
+
></pending-recordings-view>
+
</div>
+
`;
+
})
+
}
+
+
<!-- Completed/Processing Transcriptions -->
${
this.filteredTranscriptions.length === 0
? html`
···
<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
</div>
`
+
: html`
${this.filteredTranscriptions.map(
+
(t) => html`
<div class="transcription-card">
<div class="transcription-header">
<div>
···
${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""}
</div>
`,
+
)}
`
+
}
+
`
+
}
</div>
<upload-recording-modal
?open=${this.uploadModalOpen}
.classId=${this.classId}
.meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))}
+
.sections=${this.sections}
+
.userSection=${this.userSection}
@close=${this.handleModalClose}
@upload-success=${this.handleUploadSuccess}
></upload-recording-modal>
+21 -7
src/components/classes-overview.ts
···
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 4rem 2rem;
···
}
private async handleClassJoined() {
await this.loadClasses();
}
···
? html`
<div class="register-card" @click=${this.handleRegisterClick}>
<div class="register-icon">+</div>
-
<div class="register-text">Register for Class</div>
</div>
`
: ""
···
<div class="empty-state">
<h2>No classes yet</h2>
<p>You haven't been enrolled in any classes.</p>
-
</div>
-
<div class="classes-grid">
-
<div class="register-card" @click=${this.handleRegisterClick}>
-
<div class="register-icon">+</div>
-
<div class="register-text">Register for Class</div>
-
</div>
</div>
`
}
···
margin-bottom: 1rem;
}
+
.empty-state button {
+
margin-top: 2rem;
+
padding: 0.75rem 2rem;
+
background: var(--accent);
+
color: var(--white);
+
border: none;
+
border-radius: 8px;
+
font-size: 1rem;
+
font-weight: 600;
+
cursor: pointer;
+
transition: all 0.2s;
+
}
+
+
.empty-state button:hover {
+
background: color-mix(in srgb, var(--accent) 90%, black);
+
transform: translateY(-2px);
+
}
+
.loading {
text-align: center;
padding: 4rem 2rem;
···
}
private async handleClassJoined() {
+
this.showRegistrationModal = false;
await this.loadClasses();
}
···
? html`
<div class="register-card" @click=${this.handleRegisterClick}>
<div class="register-icon">+</div>
+
<div class="register-text">Register for a Class</div>
</div>
`
: ""
···
<div class="empty-state">
<h2>No classes yet</h2>
<p>You haven't been enrolled in any classes.</p>
+
<button @click=${this.handleRegisterClick}>Register for a Class</button>
</div>
`
}
+436
src/components/pending-recordings-view.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
+
interface PendingRecording {
+
id: string;
+
user_id: number;
+
filename: string;
+
original_filename: string;
+
vote_count: number;
+
created_at: number;
+
}
+
+
interface RecordingsData {
+
recordings: PendingRecording[];
+
total_users: number;
+
user_vote: string | null;
+
vote_threshold: number;
+
winning_recording_id: string | null;
+
}
+
+
@customElement("pending-recordings-view")
+
export class PendingRecordingsView extends LitElement {
+
@property({ type: String }) classId = "";
+
@property({ type: String }) meetingTimeId = "";
+
@property({ type: String }) meetingTimeLabel = "";
+
@property({ type: String }) sectionId: string | null = null;
+
+
@state() private recordings: PendingRecording[] = [];
+
@state() private userVote: string | null = null;
+
@state() private voteThreshold = 0;
+
@state() private winningRecordingId: string | null = null;
+
@state() private error: string | null = null;
+
@state() private timeRemaining = "";
+
+
private refreshInterval?: number;
+
private loadingInProgress = false;
+
+
static override styles = css`
+
:host {
+
display: block;
+
padding: 1rem;
+
}
+
+
.container {
+
max-width: 56rem;
+
margin: 0 auto;
+
}
+
+
h2 {
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.info {
+
color: var(--paynes-gray);
+
font-size: 0.875rem;
+
margin-bottom: 1.5rem;
+
}
+
+
.stats {
+
display: flex;
+
gap: 2rem;
+
margin-bottom: 1.5rem;
+
padding: 1rem;
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
border-radius: 8px;
+
}
+
+
.stat {
+
display: flex;
+
flex-direction: column;
+
gap: 0.25rem;
+
}
+
+
.stat-label {
+
font-size: 0.75rem;
+
color: var(--paynes-gray);
+
text-transform: uppercase;
+
letter-spacing: 0.05em;
+
}
+
+
.stat-value {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
}
+
+
.recordings-list {
+
display: flex;
+
flex-direction: column;
+
gap: 1rem;
+
}
+
+
.recording-card {
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1rem;
+
transition: all 0.2s;
+
}
+
+
.recording-card.voted {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 5%, transparent);
+
}
+
+
.recording-card.winning {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
}
+
+
.recording-header {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 0.75rem;
+
}
+
+
.recording-info {
+
flex: 1;
+
}
+
+
.recording-name {
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 0.25rem;
+
}
+
+
.recording-meta {
+
font-size: 0.75rem;
+
color: var(--paynes-gray);
+
}
+
+
.vote-section {
+
display: flex;
+
align-items: center;
+
gap: 1rem;
+
}
+
+
.vote-count {
+
font-size: 1.25rem;
+
font-weight: 600;
+
color: var(--accent);
+
min-width: 3rem;
+
text-align: center;
+
}
+
+
.vote-button {
+
padding: 0.5rem 1rem;
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
border: 2px solid var(--secondary);
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.vote-button:hover:not(:disabled) {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
}
+
+
.vote-button.voted {
+
border-color: var(--accent);
+
background: var(--accent);
+
color: var(--white);
+
}
+
+
.vote-button:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.delete-button {
+
padding: 0.5rem;
+
border: none;
+
background: transparent;
+
color: var(--paynes-gray);
+
cursor: pointer;
+
border-radius: 4px;
+
transition: all 0.2s;
+
}
+
+
.delete-button:hover {
+
background: color-mix(in srgb, red 10%, transparent);
+
color: red;
+
}
+
+
.winning-badge {
+
background: var(--accent);
+
color: var(--white);
+
padding: 0.25rem 0.75rem;
+
border-radius: 12px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
}
+
+
.error {
+
background: color-mix(in srgb, red 10%, transparent);
+
border: 1px solid red;
+
color: red;
+
padding: 0.75rem;
+
border-radius: 4px;
+
margin-bottom: 1rem;
+
font-size: 0.875rem;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem 1rem;
+
color: var(--paynes-gray);
+
}
+
+
.audio-player {
+
margin-top: 0.75rem;
+
}
+
+
audio {
+
width: 100%;
+
height: 2.5rem;
+
}
+
`;
+
+
override connectedCallback() {
+
super.connectedCallback();
+
this.loadRecordings();
+
// Refresh every 10 seconds
+
this.refreshInterval = setInterval(() => this.loadRecordings(), 10000);
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
if (this.refreshInterval) {
+
clearInterval(this.refreshInterval);
+
}
+
}
+
+
private async loadRecordings() {
+
if (this.loadingInProgress) return;
+
+
this.loadingInProgress = true;
+
+
try {
+
// Build URL with optional section_id parameter
+
const url = new URL(
+
`/api/classes/${this.classId}/meetings/${this.meetingTimeId}/recordings`,
+
window.location.origin,
+
);
+
if (this.sectionId !== null) {
+
url.searchParams.set("section_id", this.sectionId);
+
}
+
+
const response = await fetch(url.toString());
+
+
if (!response.ok) {
+
throw new Error("Failed to load recordings");
+
}
+
+
const data: RecordingsData = await response.json();
+
this.recordings = data.recordings;
+
this.userVote = data.user_vote;
+
this.voteThreshold = data.vote_threshold;
+
this.winningRecordingId = data.winning_recording_id;
+
+
// Calculate time remaining for first recording
+
if (this.recordings.length > 0 && this.recordings[0]) {
+
const uploadedAt = this.recordings[0].created_at;
+
const now = Date.now() / 1000;
+
const elapsed = now - uploadedAt;
+
const remaining = 30 * 60 - elapsed; // 30 minutes
+
+
if (remaining > 0) {
+
const minutes = Math.floor(remaining / 60);
+
const seconds = Math.floor(remaining % 60);
+
this.timeRemaining = `${minutes}:${seconds.toString().padStart(2, "0")}`;
+
} else {
+
this.timeRemaining = "Auto-submitting...";
+
}
+
}
+
+
this.error = null;
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to load recordings";
+
} finally {
+
this.loadingInProgress = false;
+
}
+
}
+
+
private async handleVote(recordingId: string) {
+
try {
+
const response = await fetch(`/api/recordings/${recordingId}/vote`, {
+
method: "POST",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to vote");
+
}
+
+
const data = await response.json();
+
+
// If a winner was selected, reload the page to show it in transcriptions
+
if (data.winning_recording_id) {
+
window.location.reload();
+
} else {
+
// Just reload recordings to show updated votes
+
await this.loadRecordings();
+
}
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to vote";
+
}
+
}
+
+
private async handleDelete(recordingId: string) {
+
if (!confirm("Delete this recording?")) {
+
return;
+
}
+
+
try {
+
const response = await fetch(`/api/recordings/${recordingId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to delete recording");
+
}
+
+
await this.loadRecordings();
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to delete recording";
+
}
+
}
+
+
private formatTimeAgo(timestamp: number): string {
+
const now = Date.now() / 1000;
+
const diff = now - timestamp;
+
+
if (diff < 60) return "just now";
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+
return `${Math.floor(diff / 86400)}d ago`;
+
}
+
+
override render() {
+
return html`
+
<div class="container">
+
<h2>Pending Recordings - ${this.meetingTimeLabel}</h2>
+
<p class="info">
+
Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes.
+
</p>
+
+
${this.error ? html`<div class="error">${this.error}</div>` : ""}
+
+
${
+
this.recordings.length > 0
+
? html`
+
<div class="stats">
+
<div class="stat">
+
<div class="stat-label">Recordings</div>
+
<div class="stat-value">${this.recordings.length}</div>
+
</div>
+
<div class="stat">
+
<div class="stat-label">Vote Threshold</div>
+
<div class="stat-value">${this.voteThreshold} votes</div>
+
</div>
+
<div class="stat">
+
<div class="stat-label">Time Remaining</div>
+
<div class="stat-value">${this.timeRemaining}</div>
+
</div>
+
</div>
+
+
<div class="recordings-list">
+
${this.recordings.map(
+
(recording) => html`
+
<div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}">
+
<div class="recording-header">
+
<div class="recording-info">
+
<div class="recording-name">${recording.original_filename}</div>
+
<div class="recording-meta">
+
Uploaded ${this.formatTimeAgo(recording.created_at)}
+
</div>
+
</div>
+
+
<div class="vote-section">
+
${
+
this.winningRecordingId === recording.id
+
? html`<span class="winning-badge">✨ Selected</span>`
+
: ""
+
}
+
+
<div class="vote-count">
+
${recording.vote_count} ${recording.vote_count === 1 ? "vote" : "votes"}
+
</div>
+
+
<button
+
class="vote-button ${this.userVote === recording.id ? "voted" : ""}"
+
@click=${() => this.handleVote(recording.id)}
+
?disabled=${this.winningRecordingId !== null}
+
>
+
${this.userVote === recording.id ? "✓ Voted" : "Vote"}
+
</button>
+
+
<button
+
class="delete-button"
+
@click=${() => this.handleDelete(recording.id)}
+
title="Delete recording"
+
>
+
🗑️
+
</button>
+
</div>
+
</div>
+
+
<div class="audio-player">
+
<audio controls preload="none">
+
<source src="/api/transcriptions/${recording.id}/audio" type="audio/mpeg">
+
</audio>
+
</div>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
: html`
+
<div class="empty-state">
+
<p>No recordings uploaded yet for this meeting time.</p>
+
<p>Upload a recording to get started!</p>
+
</div>
+
`
+
}
+
</div>
+
`;
+
}
+
}
+335
src/components/reset-password-form.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
import { hashPasswordClient } from "../lib/client-auth";
+
+
@customElement("reset-password-form")
+
export class ResetPasswordForm extends LitElement {
+
@property({ type: String }) token: string | null = null;
+
@state() private email: string | null = null;
+
@state() private password = "";
+
@state() private confirmPassword = "";
+
@state() private error = "";
+
@state() private isSubmitting = false;
+
@state() private isSuccess = false;
+
@state() private isLoadingEmail = false;
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.reset-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2.5rem;
+
max-width: 25rem;
+
width: 100%;
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+
}
+
+
.reset-title {
+
margin-top: 0;
+
margin-bottom: 2rem;
+
color: var(--text);
+
text-align: center;
+
font-size: 1.75rem;
+
}
+
+
.form-group {
+
margin-bottom: 1.5rem;
+
}
+
+
label {
+
display: block;
+
margin-bottom: 0.25rem;
+
font-weight: 500;
+
color: var(--text);
+
font-size: 0.875rem;
+
}
+
+
input {
+
width: 100%;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
transition: all 0.2s;
+
box-sizing: border-box;
+
}
+
+
input::placeholder {
+
color: var(--secondary);
+
opacity: 1;
+
}
+
+
input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.error-banner {
+
background: #fecaca;
+
border: 2px solid rgba(220, 38, 38, 0.8);
+
border-radius: 6px;
+
padding: 1rem;
+
margin-bottom: 1rem;
+
color: #dc2626;
+
font-weight: 500;
+
}
+
+
.btn-primary {
+
width: 100%;
+
padding: 0.75rem 1.5rem;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
background: var(--primary);
+
color: white;
+
margin-top: 0.5rem;
+
}
+
+
.btn-primary:hover:not(:disabled) {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-primary:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.back-link {
+
display: block;
+
text-align: center;
+
margin-top: 1.5rem;
+
color: var(--primary);
+
text-decoration: none;
+
font-weight: 500;
+
font-size: 0.875rem;
+
transition: all 0.2s;
+
}
+
+
.back-link:hover {
+
color: var(--accent);
+
}
+
+
.success-message {
+
text-align: center;
+
}
+
+
.success-icon {
+
font-size: 3rem;
+
margin-bottom: 1rem;
+
}
+
+
.success-text {
+
color: var(--primary);
+
font-size: 1.25rem;
+
font-weight: 500;
+
margin-bottom: 1.5rem;
+
}
+
+
.success-link {
+
display: inline-block;
+
padding: 0.75rem 1.5rem;
+
background: var(--accent);
+
color: white;
+
text-decoration: none;
+
border-radius: 6px;
+
font-weight: 500;
+
transition: all 0.2s;
+
}
+
+
.success-link:hover {
+
background: var(--primary);
+
}
+
`;
+
+
override async updated(changedProperties: Map<string, unknown>) {
+
super.updated(changedProperties);
+
+
// When token property changes and we don't have email yet, load it
+
if (
+
changedProperties.has("token") &&
+
this.token &&
+
!this.email &&
+
!this.isLoadingEmail
+
) {
+
await this.loadEmail();
+
}
+
}
+
+
private async loadEmail() {
+
this.isLoadingEmail = true;
+
this.error = "";
+
+
try {
+
const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`;
+
const response = await fetch(url);
+
const data = await response.json();
+
+
if (!response.ok) {
+
throw new Error(data.error || "Invalid or expired reset token");
+
}
+
+
this.email = data.email;
+
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to verify reset token";
+
} finally {
+
this.isLoadingEmail = false;
+
}
+
}
+
+
override render() {
+
if (!this.token) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<div class="error-banner">Invalid or missing reset token</div>
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
if (this.isLoadingEmail) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<p style="text-align: center; color: var(--text);">Verifying reset token...</p>
+
</div>
+
`;
+
}
+
+
if (this.error && !this.email) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<div class="error-banner">${this.error}</div>
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
if (this.isSuccess) {
+
return html`
+
<div class="reset-card">
+
<div class="success-message">
+
<div class="success-icon">✓</div>
+
<div class="success-text">Password reset successfully!</div>
+
<a href="/" class="success-link">Go to home</a>
+
</div>
+
</div>
+
`;
+
}
+
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
+
<form @submit=${this.handleSubmit}>
+
${
+
this.error
+
? html`<div class="error-banner">${this.error}</div>`
+
: ""
+
}
+
+
<div class="form-group">
+
<label for="password">New Password</label>
+
<input
+
type="password"
+
id="password"
+
.value=${this.password}
+
@input=${(e: Event) => {
+
this.password = (e.target as HTMLInputElement).value;
+
}}
+
required
+
minlength="8"
+
placeholder="Enter new password (min 8 characters)"
+
>
+
</div>
+
+
<div class="form-group">
+
<label for="confirm-password">Confirm Password</label>
+
<input
+
type="password"
+
id="confirm-password"
+
.value=${this.confirmPassword}
+
@input=${(e: Event) => {
+
this.confirmPassword = (e.target as HTMLInputElement).value;
+
}}
+
required
+
minlength="8"
+
placeholder="Confirm new password"
+
>
+
</div>
+
+
<button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}>
+
${this.isSubmitting ? "Resetting..." : "Reset Password"}
+
</button>
+
</form>
+
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
private async handleSubmit(e: Event) {
+
e.preventDefault();
+
this.error = "";
+
+
// Validate passwords match
+
if (this.password !== this.confirmPassword) {
+
this.error = "Passwords do not match";
+
return;
+
}
+
+
// Validate password length
+
if (this.password.length < 8) {
+
this.error = "Password must be at least 8 characters";
+
return;
+
}
+
+
this.isSubmitting = true;
+
+
try {
+
if (!this.email) {
+
throw new Error("Email not loaded");
+
}
+
+
// Hash password client-side with user's email
+
const hashedPassword = await hashPasswordClient(
+
this.password,
+
this.email,
+
);
+
+
const response = await fetch("/api/auth/reset-password", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ token: this.token, password: hashedPassword }),
+
});
+
+
const data = await response.json();
+
+
if (!response.ok) {
+
throw new Error(data.error || "Failed to reset password");
+
}
+
+
// Show success message
+
this.isSuccess = true;
+
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to reset password";
+
} finally {
+
this.isSubmitting = false;
+
}
+
}
+
}
+10 -5
src/components/transcription.ts
···
async checkHealth() {
try {
-
const response = await fetch("/api/transcriptions/health");
if (response.ok) {
const data = await response.json();
-
this.serviceAvailable = data.available;
} else {
this.serviceAvailable = false;
}
···
}
override render() {
-
const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin);
return html`
-
${!this.hasSubscription && !this.isAdmin ? html`
<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3>
<p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p>
<a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>
</div>
-
` : ''}
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}"
@dragover=${canUpload ? this.handleDragOver : null}
···
async checkHealth() {
try {
+
const response = await fetch("/api/health");
if (response.ok) {
const data = await response.json();
+
this.serviceAvailable = data.status === "healthy";
} else {
this.serviceAvailable = false;
}
···
}
override render() {
+
const canUpload =
+
this.serviceAvailable && (this.hasSubscription || this.isAdmin);
return html`
+
${
+
!this.hasSubscription && !this.isAdmin
+
? html`
<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3>
<p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p>
<a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>
</div>
+
`
+
: ""
+
}
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}"
@dragover=${canUpload ? this.handleDragOver : null}
+360 -66
src/components/upload-recording-modal.ts
···
label: string;
}
@customElement("upload-recording-modal")
export class UploadRecordingModal extends LitElement {
@property({ type: Boolean }) open = false;
@property({ type: String }) classId = "";
@property({ type: Array }) meetingTimes: MeetingTime[] = [];
@state() private selectedFile: File | null = null;
@state() private selectedMeetingTimeId: string | null = null;
@state() private uploading = false;
@state() private error: string | null = null;
static override styles = css`
:host {
···
align-items: center;
gap: 0.5rem;
}
`;
-
private handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0] ?? null;
this.error = null;
}
}
-
private handleMeetingTimeChange(e: Event) {
-
const select = e.target as HTMLSelectElement;
-
this.selectedMeetingTimeId = select.value || null;
-
}
-
private handleClose() {
-
if (this.uploading) return;
-
this.open = false;
-
this.selectedFile = null;
-
this.selectedMeetingTimeId = null;
-
this.error = null;
-
this.dispatchEvent(new CustomEvent("close"));
-
}
-
private async handleUpload() {
-
if (!this.selectedFile) {
-
this.error = "Please select a file to upload";
-
return;
-
}
-
if (!this.selectedMeetingTimeId) {
-
this.error = "Please select a meeting time";
-
return;
}
-
this.uploading = true;
-
this.error = null;
try {
const formData = new FormData();
-
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
formData.append("meeting_time_id", this.selectedMeetingTimeId);
-
const response = await fetch("/api/transcriptions", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
-
throw new Error(data.error || "Upload failed");
}
-
// Success - close modal and notify parent
this.dispatchEvent(new CustomEvent("upload-success"));
this.handleClose();
} catch (error) {
-
console.error("Upload failed:", error);
-
this.error =
-
error instanceof Error
-
? error.message
-
: "Upload failed. Please try again.";
-
} finally {
-
this.uploading = false;
}
}
override render() {
if (!this.open) return null;
···
<div class="help-text">Maximum file size: 100MB</div>
</div>
-
<div class="form-group">
-
<label for="meeting-time">Meeting Time</label>
-
<select
-
id="meeting-time"
-
@change=${this.handleMeetingTimeChange}
-
?disabled=${this.uploading}
-
required
-
>
-
<option value="">Select a meeting time...</option>
-
${this.meetingTimes.map(
-
(meeting) => html`
-
<option value=${meeting.id}>${meeting.label}</option>
-
`,
-
)}
-
</select>
-
<div class="help-text">
-
Select which meeting this recording is for
-
</div>
-
</div>
</form>
<div class="modal-footer">
-
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}>
Cancel
</button>
-
<button
-
class="btn-upload"
-
@click=${this.handleUpload}
-
?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId}
-
>
-
${
-
this.uploading
-
? html`<span class="uploading-text">Uploading...</span>`
-
: "Upload"
-
}
-
</button>
</div>
</div>
</div>
···
label: string;
}
+
interface ClassSection {
+
id: string;
+
section_number: string;
+
}
+
@customElement("upload-recording-modal")
export class UploadRecordingModal extends LitElement {
@property({ type: Boolean }) open = false;
@property({ type: String }) classId = "";
@property({ type: Array }) meetingTimes: MeetingTime[] = [];
+
@property({ type: Array }) sections: ClassSection[] = [];
+
@property({ type: String }) userSection: string | null = null;
@state() private selectedFile: File | null = null;
@state() private selectedMeetingTimeId: string | null = null;
+
@state() private selectedSectionId: string | null = null;
@state() private uploading = false;
+
@state() private uploadProgress = 0;
@state() private error: string | null = null;
+
@state() private detectedMeetingTime: string | null = null;
+
@state() private detectingMeetingTime = false;
+
@state() private uploadComplete = false;
+
@state() private uploadedTranscriptionId: string | null = null;
+
@state() private submitting = false;
+
@state() private selectedDate: string = "";
static override styles = css`
:host {
···
align-items: center;
gap: 0.5rem;
}
+
+
.meeting-time-selector {
+
display: flex;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-button {
+
padding: 0.75rem 1rem;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
color: var(--text);
+
text-align: left;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-button:hover {
+
border-color: var(--primary);
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
}
+
+
.meeting-time-button.selected {
+
background: var(--primary);
+
border-color: var(--primary);
+
color: white;
+
}
+
+
.meeting-time-button.detected {
+
border-color: var(--accent);
+
}
+
+
.meeting-time-button.detected::after {
+
content: "✨ Auto-detected";
+
margin-left: auto;
+
font-size: 0.75rem;
+
opacity: 0.8;
+
}
+
+
.detecting-text {
+
font-size: 0.875rem;
+
color: var(--paynes-gray);
+
padding: 0.5rem;
+
text-align: center;
+
font-style: italic;
+
}
`;
+
private async handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0] ?? null;
this.error = null;
+
this.detectedMeetingTime = null;
+
this.selectedMeetingTimeId = null;
+
this.uploadComplete = false;
+
this.uploadedTranscriptionId = null;
+
this.submitting = false;
+
this.selectedDate = "";
+
+
if (this.selectedFile && this.classId) {
+
// Set initial date from file
+
const fileDate = new Date(this.selectedFile.lastModified);
+
this.selectedDate = fileDate.toISOString().split("T")[0] || "";
+
// Start both detection and upload in parallel
+
this.detectMeetingTime();
+
this.startBackgroundUpload();
+
}
}
}
+
private async startBackgroundUpload() {
+
if (!this.selectedFile) return;
+
+
this.uploading = true;
+
this.uploadProgress = 0;
+
+
try {
+
const formData = new FormData();
+
formData.append("audio", this.selectedFile);
+
formData.append("class_id", this.classId);
+
+
// Send recording date (from date picker or file timestamp)
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
formData.append("recording_date", Math.floor(date.getTime() / 1000).toString());
+
} else if (this.selectedFile.lastModified) {
+
// Use file's lastModified as recording date
+
formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString());
+
}
+
+
// Don't send section_id yet - will be set via PATCH when user confirms
+
+
const xhr = new XMLHttpRequest();
+
// Track upload progress
+
xhr.upload.addEventListener("progress", (e) => {
+
if (e.lengthComputable) {
+
this.uploadProgress = Math.round((e.loaded / e.total) * 100);
+
}
+
});
+
+
// Handle completion
+
xhr.addEventListener("load", () => {
+
if (xhr.status >= 200 && xhr.status < 300) {
+
this.uploadComplete = true;
+
this.uploading = false;
+
const response = JSON.parse(xhr.responseText);
+
this.uploadedTranscriptionId = response.id;
+
} else {
+
this.uploading = false;
+
const response = JSON.parse(xhr.responseText);
+
this.error = response.error || "Upload failed";
+
}
+
});
+
// Handle errors
+
xhr.addEventListener("error", () => {
+
this.uploading = false;
+
this.error = "Upload failed. Please try again.";
+
});
+
xhr.open("POST", "/api/transcriptions");
+
xhr.send(formData);
+
} catch (error) {
+
console.error("Upload failed:", error);
+
this.uploading = false;
+
this.error =
+
error instanceof Error
+
? error.message
+
: "Upload failed. Please try again.";
}
+
}
+
private async detectMeetingTime() {
+
if (!this.classId) return;
+
+
this.detectingMeetingTime = true;
try {
const formData = new FormData();
formData.append("class_id", this.classId);
+
+
// Use selected date or file's lastModified timestamp
+
let timestamp: number;
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time to avoid timezone issues)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
timestamp = date.getTime();
+
} else if (this.selectedFile?.lastModified) {
+
timestamp = this.selectedFile.lastModified;
+
} else {
+
return;
+
}
+
formData.append("file_timestamp", timestamp.toString());
+
+
const response = await fetch("/api/transcriptions/detect-meeting-time", {
method: "POST",
body: formData,
});
if (!response.ok) {
+
console.warn("Failed to detect meeting time");
+
return;
+
}
+
+
const data = await response.json();
+
+
if (data.detected && data.meeting_time_id) {
+
this.detectedMeetingTime = data.meeting_time_id;
+
this.selectedMeetingTimeId = data.meeting_time_id;
+
}
+
} catch (error) {
+
console.warn("Error detecting meeting time:", error);
+
} finally {
+
this.detectingMeetingTime = false;
+
}
+
}
+
+
private handleMeetingTimeSelect(meetingTimeId: string) {
+
this.selectedMeetingTimeId = meetingTimeId;
+
}
+
+
private handleDateChange(e: Event) {
+
const input = e.target as HTMLInputElement;
+
this.selectedDate = input.value;
+
// Re-detect meeting time when date changes
+
if (this.selectedDate && this.classId) {
+
this.detectMeetingTime();
+
}
+
}
+
+
private handleSectionChange(e: Event) {
+
const select = e.target as HTMLSelectElement;
+
this.selectedSectionId = select.value || null;
+
}
+
+
private async handleSubmit() {
+
if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return;
+
+
this.submitting = true;
+
this.error = null;
+
+
try {
+
// Get section to use (selected override or user's section)
+
const sectionToUse = this.selectedSectionId || this.userSection;
+
+
const response = await fetch(
+
`/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`,
+
{
+
method: "PATCH",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
meeting_time_id: this.selectedMeetingTimeId,
+
section_id: sectionToUse,
+
}),
+
},
+
);
+
+
if (!response.ok) {
const data = await response.json();
+
this.error = data.error || "Failed to update meeting time";
+
this.submitting = false;
+
return;
}
+
// Success - close modal and refresh
this.dispatchEvent(new CustomEvent("upload-success"));
this.handleClose();
} catch (error) {
+
console.error("Failed to update meeting time:", error);
+
this.error = "Failed to update meeting time";
+
this.submitting = false;
}
}
+
private handleClose() {
+
if (this.uploading || this.submitting) return;
+
this.open = false;
+
this.selectedFile = null;
+
this.selectedMeetingTimeId = null;
+
this.selectedSectionId = null;
+
this.error = null;
+
this.detectedMeetingTime = null;
+
this.detectingMeetingTime = false;
+
this.uploadComplete = false;
+
this.uploadProgress = 0;
+
this.uploadedTranscriptionId = null;
+
this.submitting = false;
+
this.selectedDate = "";
+
this.dispatchEvent(new CustomEvent("close"));
+
}
+
override render() {
if (!this.open) return null;
···
<div class="help-text">Maximum file size: 100MB</div>
</div>
+
${
+
this.selectedFile
+
? html`
+
<div class="form-group">
+
<label for="date">Recording Date</label>
+
<input
+
type="date"
+
id="date"
+
.value=${this.selectedDate}
+
@change=${this.handleDateChange}
+
?disabled=${this.uploading}
+
style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"
+
/>
+
<div class="help-text">
+
Change the date to detect the correct meeting time
+
</div>
+
</div>
+
+
<div class="form-group">
+
<label>Meeting Time</label>
+
${
+
this.detectingMeetingTime
+
? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>`
+
: html`
+
<div class="meeting-time-selector">
+
${this.meetingTimes.map(
+
(meeting) => html`
+
<button
+
type="button"
+
class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}"
+
@click=${() => this.handleMeetingTimeSelect(meeting.id)}
+
?disabled=${this.uploading}
+
>
+
${meeting.label}
+
</button>
+
`,
+
)}
+
</div>
+
`
+
}
+
<div class="help-text">
+
${
+
this.detectedMeetingTime
+
? "Auto-detected based on recording date. You can change if needed."
+
: "Select which meeting this recording is for"
+
}
+
</div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
this.sections.length > 0 && this.selectedFile
+
? html`
+
<div class="form-group">
+
<label for="section">Section</label>
+
<select
+
id="section"
+
@change=${this.handleSectionChange}
+
?disabled=${this.uploading}
+
.value=${this.selectedSectionId || this.userSection || ""}
+
>
+
<option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option>
+
${this.sections
+
.filter((section) => section.id !== this.userSection)
+
.map(
+
(section) => html`
+
<option value=${section.id}>${section.section_number}</option>
+
`,
+
)}
+
</select>
+
<div class="help-text">
+
Select which section this recording is for (defaults to your section)
+
</div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
this.uploading || this.uploadComplete
+
? html`
+
<div class="form-group">
+
<label>Upload Status</label>
+
<div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;">
+
${
+
this.uploadComplete
+
? html`
+
<div style="color: green; font-weight: 500;">
+
✓ Upload complete! Select a meeting time to continue.
+
</div>
+
`
+
: html`
+
<div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;">
+
Uploading... ${this.uploadProgress}%
+
</div>
+
<div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;">
+
<div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div>
+
</div>
+
`
+
}
+
</div>
+
</div>
+
`
+
: ""
+
}
</form>
<div class="modal-footer">
+
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
Cancel
</button>
+
${
+
this.uploadComplete && this.selectedMeetingTimeId
+
? html`
+
<button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}>
+
${this.submitting ? "Submitting..." : "Confirm & Submit"}
+
</button>
+
`
+
: ""
+
}
</div>
</div>
</div>
+44 -31
src/components/user-modal.ts
···
color: #991b1b;
}
.session-list, .passkey-list {
list-style: none;
padding: 0;
···
private async handleChangeEmail(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
-
const input = form.querySelector("input") as HTMLInputElement;
const email = input.value.trim();
if (!email || !email.includes("@")) {
alert("Please enter a valid email");
···
const res = await fetch(`/api/admin/users/${this.userId}/email`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ email }),
});
if (!res.ok) {
···
throw new Error(data.error || "Failed to update email");
}
-
alert("Email updated successfully");
await this.loadUserDetails();
this.dispatchEvent(
new CustomEvent("user-updated", { bubbles: true, composed: true }),
···
private async handleChangePassword(e: Event) {
e.preventDefault();
-
const form = e.target as HTMLFormElement;
-
const input = form.querySelector("input") as HTMLInputElement;
-
const password = input.value;
-
-
if (password.length < 8) {
-
alert("Password must be at least 8 characters");
-
return;
-
}
if (
!confirm(
-
"Are you sure you want to change this user's password? This will log them out of all devices.",
)
) {
return;
}
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLButtonElement;
submitBtn.disabled = true;
-
submitBtn.textContent = "Updating...";
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/password`, {
-
method: "PUT",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ password }),
-
});
if (!res.ok) {
-
throw new Error("Failed to update password");
}
alert(
-
"Password updated successfully. User has been logged out of all devices.",
);
-
input.value = "";
-
await this.loadUserDetails();
-
} catch {
-
alert("Failed to update password");
} finally {
submitBtn.disabled = false;
-
submitBtn.textContent = "Update Password";
}
}
···
<label class="form-label" for="new-email">New Email</label>
<input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
</div>
<div class="detail-section">
-
<h3 class="detail-section-title">Change Password</h3>
<form @submit=${this.handleChangePassword}>
-
<div class="form-group">
-
<label class="form-label" for="new-password">New Password</label>
-
<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
-
</div>
-
<button type="submit" class="btn btn-primary">Update Password</button>
</form>
</div>
···
color: #991b1b;
}
+
.info-text {
+
color: var(--text);
+
font-size: 0.875rem;
+
margin: 0 0 1rem 0;
+
line-height: 1.5;
+
opacity: 0.8;
+
}
+
.session-list, .passkey-list {
list-style: none;
padding: 0;
···
private async handleChangeEmail(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
+
const input = form.querySelector('input[type="email"]') as HTMLInputElement;
+
const checkbox = form.querySelector(
+
'input[type="checkbox"]',
+
) as HTMLInputElement;
const email = input.value.trim();
+
const skipVerification = checkbox?.checked || false;
if (!email || !email.includes("@")) {
alert("Please enter a valid email");
···
const res = await fetch(`/api/admin/users/${this.userId}/email`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ email, skipVerification }),
});
if (!res.ok) {
···
throw new Error(data.error || "Failed to update email");
}
+
const data = await res.json();
+
alert(data.message || "Email updated successfully");
await this.loadUserDetails();
this.dispatchEvent(
new CustomEvent("user-updated", { bubbles: true, composed: true }),
···
private async handleChangePassword(e: Event) {
e.preventDefault();
if (
!confirm(
+
"Send a password reset email to this user? They will receive a link to set a new password.",
)
) {
return;
}
+
const form = e.target as HTMLFormElement;
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLButtonElement;
submitBtn.disabled = true;
+
submitBtn.textContent = "Sending...";
try {
+
const res = await fetch(
+
`/api/admin/users/${this.userId}/password-reset`,
+
{
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
},
+
);
if (!res.ok) {
+
const data = await res.json();
+
throw new Error(data.error || "Failed to send password reset email");
}
alert(
+
"Password reset email sent successfully. The user will receive a link to set a new password.",
);
+
} catch (err) {
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to send password reset email";
} finally {
submitBtn.disabled = false;
+
submitBtn.textContent = "Send Reset Email";
}
}
···
<label class="form-label" for="new-email">New Email</label>
<input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
</div>
+
<div class="form-group" style="margin-top: 0.5rem;">
+
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">
+
<input type="checkbox" id="skip-verification" style="cursor: pointer;">
+
<span>Skip verification (use if user is locked out of email)</span>
+
</label>
+
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
</div>
<div class="detail-section">
+
<h3 class="detail-section-title">Password Reset</h3>
+
<p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>
<form @submit=${this.handleChangePassword}>
+
<button type="submit" class="btn btn-primary">Send Reset Email</button>
</form>
</div>
+153 -46
src/components/user-settings.ts
···
canceled_at: number | null;
}
-
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
···
@state() addingPasskey = false;
@state() emailNotificationsEnabled = true;
@state() deletingAccount = false;
static override styles = css`
:host {
···
line-height: 1.5;
}
.session-list {
display: flex;
flex-direction: column;
···
override async connectedCallback() {
super.connectedCallback();
this.passkeySupported = isPasskeySupported();
-
// Check for tab query parameter
const params = new URLSearchParams(window.location.search);
const tab = params.get("tab");
if (tab && this.isValidTab(tab)) {
this.currentPage = tab as SettingsPage;
}
-
await this.loadUser();
await this.loadSessions();
await this.loadSubscription();
···
}
private isValidTab(tab: string): boolean {
-
return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab);
}
private setTab(tab: SettingsPage) {
···
// Reload passkeys
await this.loadPasskeys();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete passkey";
}
}
···
this.error = "";
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
-
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to logout";
return;
}
-
window.location.href = "/";
} catch (err) {
this.error = err instanceof Error ? err.message : "Failed to logout";
···
this.deletingAccount = true;
this.error = "";
document.body.style.cursor = "wait";
-
try {
const response = await fetch("/api/user", {
method: "DELETE",
···
async handleUpdateEmail() {
this.error = "";
if (!this.newEmail) {
this.error = "Email required";
return;
}
try {
const response = await fetch("/api/user/email", {
method: "PUT",
···
body: JSON.stringify({ email: this.newEmail }),
});
if (!response.ok) {
-
const data = await response.json();
this.error = data.error || "Failed to update email";
return;
}
-
// Reload user data
-
await this.loadUser();
this.editingEmail = false;
this.newEmail = "";
} catch {
this.error = "Failed to update email";
}
}
···
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Profile Information</h2>
···
<div class="field-group">
<label class="field-label">Email</label>
${
-
this.editingEmail
? html`
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
type="email"
···
<button
class="btn btn-affirmative btn-small"
@click=${this.handleUpdateEmail}
>
-
Save
</button>
<button
class="btn btn-neutral btn-small"
···
</button>
</div>
`
-
: html`
<div class="field-row">
<div class="field-value">${this.user.email}</div>
<button
···
@click=${() => {
this.editingEmail = true;
this.newEmail = this.user?.email ?? "";
}}
>
Change
···
renderSessionsPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Active Sessions</h2>
${
···
`;
}
-
const hasActiveSubscription = this.subscription && (
-
this.subscription.status === "active" ||
-
this.subscription.status === "trialing"
-
);
if (this.subscription && !hasActiveSubscription) {
// Has a subscription but it's not active (canceled, expired, etc.)
-
const statusColor =
-
this.subscription.status === "canceled" ? "var(--accent)" :
-
"var(--secondary)";
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
</div>
</div>
-
${this.subscription.canceled_at ? html`
<div class="field-group">
<label class="field-label">Canceled At</label>
<div class="field-value" style="color: var(--accent);">
${this.formatDate(this.subscription.canceled_at)}
</div>
</div>
-
` : ""}
<div class="field-group" style="margin-top: 2rem;">
<button
···
if (hasActiveSubscription) {
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
">
${this.subscription.status}
</span>
-
${this.subscription.cancel_at_period_end ? html`
<span style="color: var(--accent); font-size: 0.875rem;">
(Cancels at end of period)
</span>
-
` : ""}
</div>
</div>
-
${this.subscription.current_period_start && this.subscription.current_period_end ? html`
<div class="field-group">
<label class="field-label">Current Period</label>
<div class="field-value">
···
${this.formatDate(this.subscription.current_period_end)}
</div>
</div>
-
` : ""}
<div class="field-group" style="margin-top: 2rem;">
<button
···
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Billing & Subscription</h2>
<p class="field-description" style="margin-bottom: 1.5rem;">
···
renderDangerPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section danger-section">
<h2 class="section-title">Delete Account</h2>
<p class="danger-text">
···
renderNotificationsPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
<div class="section">
<h2 class="section-title">Email Notifications</h2>
<p style="color: var(--text); margin-bottom: 1rem;">
···
const target = e.target as HTMLInputElement;
this.emailNotificationsEnabled = target.checked;
this.error = "";
-
try {
const response = await fetch("/api/user/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email_notifications_enabled: this.emailNotificationsEnabled,
}),
});
-
if (!response.ok) {
const data = await response.json();
-
throw new Error(data.error || "Failed to update notification settings");
}
} catch (err) {
// Revert on error
this.emailNotificationsEnabled = !target.checked;
target.checked = !target.checked;
-
this.error = err instanceof Error ? err.message : "Failed to update notification settings";
}
}}
/>
···
canceled_at: number | null;
}
+
type SettingsPage =
+
| "account"
+
| "sessions"
+
| "passkeys"
+
| "billing"
+
| "notifications"
+
| "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
···
@state() addingPasskey = false;
@state() emailNotificationsEnabled = true;
@state() deletingAccount = false;
+
@state() emailChangeMessage = "";
+
@state() pendingEmailChange = "";
+
@state() updatingEmail = false;
static override styles = css`
:host {
···
line-height: 1.5;
}
+
.success-message {
+
padding: 1rem;
+
background: rgba(76, 175, 80, 0.1);
+
border: 1px solid rgba(76, 175, 80, 0.3);
+
border-radius: 0.5rem;
+
color: var(--text);
+
}
+
+
.spinner {
+
display: inline-block;
+
width: 1rem;
+
height: 1rem;
+
border: 2px solid rgba(255, 255, 255, 0.3);
+
border-top-color: white;
+
border-radius: 50%;
+
animation: spin 0.6s linear infinite;
+
}
+
+
@keyframes spin {
+
to {
+
transform: rotate(360deg);
+
}
+
}
+
.session-list {
display: flex;
flex-direction: column;
···
override async connectedCallback() {
super.connectedCallback();
this.passkeySupported = isPasskeySupported();
+
// Check for tab query parameter
const params = new URLSearchParams(window.location.search);
const tab = params.get("tab");
if (tab && this.isValidTab(tab)) {
this.currentPage = tab as SettingsPage;
}
+
await this.loadUser();
await this.loadSessions();
await this.loadSubscription();
···
}
private isValidTab(tab: string): boolean {
+
return [
+
"account",
+
"sessions",
+
"passkeys",
+
"billing",
+
"notifications",
+
"danger",
+
].includes(tab);
}
private setTab(tab: SettingsPage) {
···
// Reload passkeys
await this.loadPasskeys();
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to delete passkey";
}
}
···
this.error = "";
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
+
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to logout";
return;
}
+
window.location.href = "/";
} catch (err) {
this.error = err instanceof Error ? err.message : "Failed to logout";
···
this.deletingAccount = true;
this.error = "";
document.body.style.cursor = "wait";
+
try {
const response = await fetch("/api/user", {
method: "DELETE",
···
async handleUpdateEmail() {
this.error = "";
+
this.emailChangeMessage = "";
if (!this.newEmail) {
this.error = "Email required";
return;
}
+
this.updatingEmail = true;
try {
const response = await fetch("/api/user/email", {
method: "PUT",
···
body: JSON.stringify({ email: this.newEmail }),
});
+
const data = await response.json();
+
if (!response.ok) {
this.error = data.error || "Failed to update email";
return;
}
+
// Show success message with pending email
+
this.emailChangeMessage = data.message || "Verification email sent";
+
this.pendingEmailChange = data.pendingEmail || this.newEmail;
this.editingEmail = false;
this.newEmail = "";
} catch {
this.error = "Failed to update email";
+
} finally {
+
this.updatingEmail = false;
}
}
···
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Profile Information</h2>
···
<div class="field-group">
<label class="field-label">Email</label>
${
+
this.emailChangeMessage
? html`
+
<div class="success-message" style="margin-bottom: 1rem;">
+
${this.emailChangeMessage}
+
${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""}
+
</div>
+
<div class="field-row">
+
<div class="field-value">${this.user.email}</div>
+
</div>
+
`
+
: this.editingEmail
+
? html`
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
type="email"
···
<button
class="btn btn-affirmative btn-small"
@click=${this.handleUpdateEmail}
+
?disabled=${this.updatingEmail}
>
+
${this.updatingEmail ? html`<span class="spinner"></span>` : "Save"}
</button>
<button
class="btn btn-neutral btn-small"
···
</button>
</div>
`
+
: html`
<div class="field-row">
<div class="field-value">${this.user.email}</div>
<button
···
@click=${() => {
this.editingEmail = true;
this.newEmail = this.user?.email ?? "";
+
this.emailChangeMessage = "";
}}
>
Change
···
renderSessionsPage() {
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Active Sessions</h2>
${
···
`;
}
+
const hasActiveSubscription =
+
this.subscription &&
+
(this.subscription.status === "active" ||
+
this.subscription.status === "trialing");
if (this.subscription && !hasActiveSubscription) {
// Has a subscription but it's not active (canceled, expired, etc.)
+
const statusColor =
+
this.subscription.status === "canceled"
+
? "var(--accent)"
+
: "var(--secondary)";
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
</div>
</div>
+
${
+
this.subscription.canceled_at
+
? html`
<div class="field-group">
<label class="field-label">Canceled At</label>
<div class="field-value" style="color: var(--accent);">
${this.formatDate(this.subscription.canceled_at)}
</div>
</div>
+
`
+
: ""
+
}
<div class="field-group" style="margin-top: 2rem;">
<button
···
if (hasActiveSubscription) {
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
">
${this.subscription.status}
</span>
+
${
+
this.subscription.cancel_at_period_end
+
? html`
<span style="color: var(--accent); font-size: 0.875rem;">
(Cancels at end of period)
</span>
+
`
+
: ""
+
}
</div>
</div>
+
${
+
this.subscription.current_period_start &&
+
this.subscription.current_period_end
+
? html`
<div class="field-group">
<label class="field-label">Current Period</label>
<div class="field-value">
···
${this.formatDate(this.subscription.current_period_end)}
</div>
</div>
+
`
+
: ""
+
}
<div class="field-group" style="margin-top: 2rem;">
<button
···
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Billing & Subscription</h2>
<p class="field-description" style="margin-bottom: 1.5rem;">
···
renderDangerPage() {
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section danger-section">
<h2 class="section-title">Delete Account</h2>
<p class="danger-text">
···
renderNotificationsPage() {
return html`
<div class="content-inner">
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Email Notifications</h2>
<p style="color: var(--text); margin-bottom: 1rem;">
···
const target = e.target as HTMLInputElement;
this.emailNotificationsEnabled = target.checked;
this.error = "";
+
try {
const response = await fetch("/api/user/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+
email_notifications_enabled:
+
this.emailNotificationsEnabled,
}),
});
+
if (!response.ok) {
const data = await response.json();
+
throw new Error(
+
data.error || "Failed to update notification settings",
+
);
}
} catch (err) {
// Revert on error
this.emailNotificationsEnabled = !target.checked;
target.checked = !target.checked;
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to update notification settings";
}
}}
/>
+98 -59
src/db/schema.ts
···
import { Database } from "bun:sqlite";
-
export const db = new Database("thistle.db");
// Schema version tracking
db.run(`
···
const migrations = [
{
version: 1,
-
name: "Complete schema with class system",
sql: `
-- Users table
CREATE TABLE IF NOT EXISTS users (
···
avatar TEXT DEFAULT 'd',
role TEXT NOT NULL DEFAULT 'user',
last_login INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
···
CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year);
CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived);
-- Class members table
CREATE TABLE IF NOT EXISTS class_members (
···
CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id);
CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status);
CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);
-
`,
-
},
-
{
-
version: 2,
-
name: "Add section column to classes table",
-
sql: `
-
ALTER TABLE classes ADD COLUMN section TEXT;
-
CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code);
-
`,
-
},
-
{
-
version: 3,
-
name: "Add class waitlist table",
-
sql: `
CREATE TABLE IF NOT EXISTS class_waitlist (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
course_code TEXT NOT NULL,
course_name TEXT NOT NULL,
professor TEXT NOT NULL,
-
section TEXT,
semester TEXT NOT NULL,
year INTEGER NOT NULL,
additional_info TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
···
CREATE INDEX IF NOT EXISTS idx_waitlist_user_id ON class_waitlist(user_id);
CREATE INDEX IF NOT EXISTS idx_waitlist_course_code ON class_waitlist(course_code);
-
`,
-
},
-
{
-
version: 4,
-
name: "Add meeting_times to class_waitlist",
-
sql: `
-
ALTER TABLE class_waitlist ADD COLUMN meeting_times TEXT;
-
`,
-
},
-
{
-
version: 5,
-
name: "Remove section columns",
-
sql: `
-
DROP INDEX IF EXISTS idx_classes_section;
-
ALTER TABLE classes DROP COLUMN section;
-
ALTER TABLE class_waitlist DROP COLUMN section;
-
`,
-
},
-
{
-
version: 6,
-
name: "Add subscriptions table for Polar integration",
-
sql: `
-- Subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
···
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
-
`,
-
},
-
{
-
version: 7,
-
name: "Create ghost user for deleted accounts",
-
sql: `
-
-- Create a ghost user account for orphaned transcriptions
-
INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at)
-
VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now'));
-
`,
-
},
-
{
-
version: 8,
-
name: "Add email verification system",
-
sql: `
-
-- Add email verification flag to users
-
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0;
-- Email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
···
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
`,
},
{
version: 4,
-
name: "Add email notification preferences",
sql: `
-
ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1;
-
`,
},
];
···
import { Database } from "bun:sqlite";
+
// Use test database when NODE_ENV is test
+
const dbPath =
+
process.env.NODE_ENV === "test" ? "thistle.test.db" : "thistle.db";
+
export const db = new Database(dbPath);
+
+
console.log(`[Database] Using database: ${dbPath}`);
// Schema version tracking
db.run(`
···
const migrations = [
{
version: 1,
+
name: "Initial schema with all tables and constraints",
sql: `
-- Users table
CREATE TABLE IF NOT EXISTS users (
···
avatar TEXT DEFAULT 'd',
role TEXT NOT NULL DEFAULT 'user',
last_login INTEGER,
+
email_verified BOOLEAN DEFAULT 0,
+
email_notifications_enabled BOOLEAN DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
+
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
···
CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year);
CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived);
+
CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code);
-- Class members table
CREATE TABLE IF NOT EXISTS class_members (
···
CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id);
CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status);
CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_meeting_time_id ON transcriptions(meeting_time_id);
+
+
-- Class waitlist table
CREATE TABLE IF NOT EXISTS class_waitlist (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
course_code TEXT NOT NULL,
course_name TEXT NOT NULL,
professor TEXT NOT NULL,
semester TEXT NOT NULL,
year INTEGER NOT NULL,
+
meeting_times TEXT,
additional_info TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
···
CREATE INDEX IF NOT EXISTS idx_waitlist_user_id ON class_waitlist(user_id);
CREATE INDEX IF NOT EXISTS idx_waitlist_course_code ON class_waitlist(course_code);
+
-- Subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
···
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
-- Email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
···
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
+
+
-- Email change tokens table
+
CREATE TABLE IF NOT EXISTS email_change_tokens (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
new_email TEXT NOT NULL,
+
token TEXT NOT NULL UNIQUE,
+
expires_at INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_email_change_tokens_user_id ON email_change_tokens(user_id);
+
CREATE INDEX IF NOT EXISTS idx_email_change_tokens_token ON email_change_tokens(token);
+
+
-- Create ghost user for deleted accounts
+
INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at)
+
VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now'));
`,
},
{
+
version: 2,
+
name: "Add sections support to classes and class members",
+
sql: `
+
-- Add section_number to classes (nullable for existing classes)
+
ALTER TABLE classes ADD COLUMN section_number TEXT;
+
+
-- Add section_id to class_members (nullable - NULL means default section)
+
ALTER TABLE class_members ADD COLUMN section_id TEXT;
+
+
-- Create sections table to track all available sections for a class
+
CREATE TABLE IF NOT EXISTS class_sections (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
section_number TEXT NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
UNIQUE(class_id, section_number)
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_class_sections_class_id ON class_sections(class_id);
+
+
-- Add section_id to transcriptions to track which section uploaded it
+
ALTER TABLE transcriptions ADD COLUMN section_id TEXT;
+
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id);
+
`,
+
},
+
{
+
version: 3,
+
name: "Add voting system for collaborative recording selection",
+
sql: `
+
-- Add vote count to transcriptions
+
ALTER TABLE transcriptions ADD COLUMN vote_count INTEGER NOT NULL DEFAULT 0;
+
+
-- Add auto-submitted flag to track if transcription was auto-selected
+
ALTER TABLE transcriptions ADD COLUMN auto_submitted BOOLEAN DEFAULT 0;
+
+
-- Create votes table to track who voted for which recording
+
CREATE TABLE IF NOT EXISTS recording_votes (
+
id TEXT PRIMARY KEY,
+
transcription_id TEXT NOT NULL,
+
user_id INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (transcription_id) REFERENCES transcriptions(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+
UNIQUE(transcription_id, user_id)
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);
+
CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id);
+
`,
+
},
+
{
version: 4,
+
name: "Add recording_date to transcriptions for chronological ordering",
sql: `
+
-- Add recording_date (timestamp when the recording was made, not uploaded)
+
-- Defaults to created_at for existing records
+
ALTER TABLE transcriptions ADD COLUMN recording_date INTEGER;
+
+
-- Set recording_date to created_at for existing records
+
UPDATE transcriptions SET recording_date = created_at WHERE recording_date IS NULL;
+
+
-- Create index for ordering by recording date
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_recording_date ON transcriptions(recording_date);
+
`,
},
];
+4 -2
src/index.test.README.md
···
- `PUT /api/passkeys/:id` - Update passkey name
- `DELETE /api/passkeys/:id` - Delete passkey
### Transcription Endpoints
-
- `GET /api/transcriptions/health` - Check transcription service health
- `GET /api/transcriptions` - List user transcriptions
- `POST /api/transcriptions` - Upload audio file and start transcription
- `GET /api/transcriptions/:id` - Get transcription details
···
- `PUT /api/admin/users/:id/role` - Update user role
- `PUT /api/admin/users/:id/name` - Update user name
- `PUT /api/admin/users/:id/email` - Update user email
-
- `PUT /api/admin/users/:id/password` - Update user password
- `GET /api/admin/users/:id/sessions` - List user sessions
- `DELETE /api/admin/users/:id/sessions` - Delete all user sessions
- `DELETE /api/admin/users/:id/sessions/:sessionId` - Delete specific session
···
- `PUT /api/passkeys/:id` - Update passkey name
- `DELETE /api/passkeys/:id` - Delete passkey
+
### Health Endpoint
+
- `GET /api/health` - Check service health (database, whisper, storage)
+
### Transcription Endpoints
- `GET /api/transcriptions` - List user transcriptions
- `POST /api/transcriptions` - Upload audio file and start transcription
- `GET /api/transcriptions/:id` - Get transcription details
···
- `PUT /api/admin/users/:id/role` - Update user role
- `PUT /api/admin/users/:id/name` - Update user name
- `PUT /api/admin/users/:id/email` - Update user email
+
- `POST /api/admin/users/:id/password-reset` - Send password reset email
- `GET /api/admin/users/:id/sessions` - List user sessions
- `DELETE /api/admin/users/:id/sessions` - Delete all user sessions
- `DELETE /api/admin/users/:id/sessions/:sessionId` - Delete specific session
+353 -747
src/index.test.ts
···
expect,
test,
} from "bun:test";
-
import db from "./db/schema";
import { hashPasswordClient } from "./lib/client-auth";
-
// Test server URL - uses port 3001 for testing to avoid conflicts
const TEST_PORT = 3001;
const BASE_URL = `http://localhost:${TEST_PORT}`;
-
// Check if server is available
-
let serverAvailable = false;
beforeAll(async () => {
try {
-
const response = await fetch(`${BASE_URL}/api/transcriptions/health`, {
-
signal: AbortSignal.timeout(1000),
-
});
-
serverAvailable = response.ok || response.status === 404;
} catch {
-
console.warn(
-
`\n⚠️ Test server not running on port ${TEST_PORT}. Start it with:\n PORT=${TEST_PORT} bun run src/index.ts\n Then run tests in another terminal.\n`,
-
);
-
serverAvailable = false;
}
});
// Test user credentials
···
});
}
-
// Cleanup helpers
-
function cleanupTestData() {
-
// Delete test users and their related data (cascade will handle most of it)
-
// Include 'newemail%' to catch users whose emails were updated during tests
-
db.run(
-
"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
);
-
db.run(
-
"DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
);
-
db.run(
-
"DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
);
-
db.run(
-
"DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",
-
);
-
// Clear ALL rate limit data to prevent accumulation across tests
-
// (IP-based rate limits don't contain test/admin in the key)
-
db.run("DELETE FROM rate_limit_attempts");
-
}
-
beforeEach(() => {
-
if (serverAvailable) {
-
cleanupTestData();
}
-
});
-
afterAll(() => {
-
if (serverAvailable) {
-
cleanupTestData();
}
-
});
-
// Helper to skip tests if server is not available
-
function serverTest(name: string, fn: () => void | Promise<void>) {
-
test(name, async () => {
-
if (!serverAvailable) {
-
console.log(`⏭️ Skipping: ${name} (server not running)`);
-
return;
-
}
-
await fn();
-
});
}
describe("API Endpoints - Authentication", () => {
describe("POST /api/auth/register", () => {
-
serverTest("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
···
}),
});
-
expect(response.status).toBe(200);
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
-
expect(extractSessionCookie(response)).toBeTruthy();
});
-
serverTest("should reject registration with missing email", async () => {
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
});
-
serverTest(
-
"should reject registration with invalid password format",
-
async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: "short",
-
}),
-
});
-
-
expect(response.status).toBe(400);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid password format");
-
},
-
);
-
-
serverTest("should reject duplicate email registration", async () => {
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
-
// First registration
-
await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
-
// Duplicate registration
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
}),
});
expect(response.status).toBe(400);
const data = await response.json();
-
expect(data.error).toBe("Email already registered");
});
-
serverTest("should enforce rate limiting on registration", async () => {
-
const hashedPassword = await clientHashPassword(
-
"test@example.com",
-
"password",
-
);
-
-
// Make registration attempts until rate limit is hit (limit is 5 per hour)
-
let rateLimitHit = false;
-
for (let i = 0; i < 10; i++) {
-
const response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: `test${i}@example.com`,
-
password: hashedPassword,
-
}),
-
});
-
-
if (response.status === 429) {
-
rateLimitHit = true;
-
break;
-
}
-
}
-
-
// Verify that rate limiting was triggered
-
expect(rateLimitHit).toBe(true);
-
});
-
});
-
-
describe("POST /api/auth/login", () => {
-
serverTest("should login successfully with valid credentials", async () => {
-
// Register user first
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
);
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
···
}),
});
-
// Login
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_USER.email,
password: hashedPassword,
}),
});
-
expect(response.status).toBe(200);
const data = await response.json();
-
expect(data.user).toBeDefined();
-
expect(data.user.email).toBe(TEST_USER.email);
-
expect(extractSessionCookie(response)).toBeTruthy();
});
-
serverTest("should reject login with invalid credentials", async () => {
-
// Register user first
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
);
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email: TEST_USER.email,
password: hashedPassword,
}),
});
-
// Login with wrong password
-
const wrongPassword = await clientHashPassword(
-
TEST_USER.email,
-
"WrongPassword123!",
-
);
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: wrongPassword,
-
}),
-
});
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid email or password");
-
});
-
-
serverTest("should reject login with missing fields", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
}),
-
});
-
-
expect(response.status).toBe(400);
-
const data = await response.json();
-
expect(data.error).toBe("Email and password required");
-
});
-
-
serverTest("should enforce rate limiting on login attempts", async () => {
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
-
// Make 11 login attempts (limit is 10 per 15 minutes per IP)
let rateLimitHit = false;
-
for (let i = 0; i < 11; i++) {
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email: TEST_USER.email,
password: hashedPassword,
}),
});
···
});
});
-
describe("POST /api/auth/logout", () => {
-
serverTest("should logout successfully", async () => {
// Register and login
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const loginResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(loginResponse);
-
-
// Logout
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/logout`,
-
sessionCookie,
-
{
-
method: "POST",
-
},
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
-
// Verify cookie is cleared
-
const setCookie = response.headers.get("set-cookie");
-
expect(setCookie).toContain("Max-Age=0");
-
});
-
-
serverTest("should logout even without valid session", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/logout`, {
-
method: "POST",
-
});
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
});
-
});
-
-
describe("GET /api/auth/me", () => {
-
serverTest(
-
"should return current user info when authenticated",
-
async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
-
-
// Get current user
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
sessionCookie,
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.email).toBe(TEST_USER.email);
-
expect(data.name).toBe(TEST_USER.name);
-
expect(data.role).toBeDefined();
-
},
-
);
-
-
serverTest("should return 401 when not authenticated", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/me`);
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Not authenticated");
-
});
-
-
serverTest("should return 401 with invalid session", async () => {
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
"invalid-session",
-
);
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid session");
-
});
-
});
-
});
-
-
describe("API Endpoints - Session Management", () => {
-
describe("GET /api/sessions", () => {
-
serverTest("should return user sessions", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
-
-
// Get sessions
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
sessionCookie,
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.sessions).toBeDefined();
-
expect(data.sessions.length).toBeGreaterThan(0);
-
expect(data.sessions[0]).toHaveProperty("id");
-
expect(data.sessions[0]).toHaveProperty("ip_address");
-
expect(data.sessions[0]).toHaveProperty("user_agent");
-
});
-
-
serverTest("should require authentication", async () => {
-
const response = await fetch(`${BASE_URL}/api/sessions`);
-
-
expect(response.status).toBe(401);
-
});
-
});
-
-
describe("DELETE /api/sessions", () => {
-
serverTest("should delete specific session", async () => {
-
// Register user and create multiple sessions
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const session1Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const session1Cookie = extractSessionCookie(session1Response);
-
-
const session2Response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const session2Cookie = extractSessionCookie(session2Response);
-
-
// Get sessions list
-
const sessionsResponse = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
session1Cookie,
-
);
-
const sessionsData = await sessionsResponse.json();
-
const targetSessionId = sessionsData.sessions.find(
-
(s: { id: string }) => s.id === session2Cookie,
-
)?.id;
-
-
// Delete session 2
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
session1Cookie,
-
{
-
method: "DELETE",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ sessionId: targetSessionId }),
-
},
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
-
// Verify session 2 is deleted
-
const verifyResponse = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
session2Cookie,
-
);
-
expect(verifyResponse.status).toBe(401);
-
});
-
-
serverTest("should not delete another user's session", async () => {
-
// Register two users
-
const hashedPassword1 = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const user1Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword1,
-
}),
-
});
-
const user1Cookie = extractSessionCookie(user1Response);
-
-
const hashedPassword2 = await clientHashPassword(
-
TEST_USER_2.email,
-
TEST_USER_2.password,
-
);
-
const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER_2.email,
-
password: hashedPassword2,
-
}),
-
});
-
const user2Cookie = extractSessionCookie(user2Response);
-
-
// Try to delete user2's session using user1's credentials
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
user1Cookie,
-
{
-
method: "DELETE",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ sessionId: user2Cookie }),
-
},
-
);
-
-
expect(response.status).toBe(404);
-
});
-
-
serverTest("should not delete current session", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Try to delete own current session
const response = await authRequest(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
-
serverTest("should delete user account", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Delete account
const response = await authRequest(
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
method: "DELETE",
});
···
});
describe("PUT /api/user/email", () => {
-
serverTest("should update user email", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
-
// Update email
const newEmail = "newemail@example.com";
const response = await authRequest(
`${BASE_URL}/api/user/email`,
···
const data = await response.json();
expect(data.success).toBe(true);
// Verify email updated
const meResponse = await authRequest(
`${BASE_URL}/api/auth/me`,
···
expect(meData.email).toBe(newEmail);
});
-
serverTest("should reject duplicate email", async () => {
// Register two users
-
const hashedPassword1 = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword1,
-
}),
-
});
-
-
const hashedPassword2 = await clientHashPassword(
-
TEST_USER_2.email,
-
TEST_USER_2.password,
-
);
-
const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER_2.email,
-
password: hashedPassword2,
-
}),
-
});
-
const user2Cookie = extractSessionCookie(user2Response);
// Try to update user2's email to user1's email
const response = await authRequest(
···
},
);
-
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
});
describe("PUT /api/user/password", () => {
-
serverTest("should update user password", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Update password
const newPassword = await clientHashPassword(
···
expect(loginResponse.status).toBe(200);
});
-
serverTest("should reject invalid password format", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Try to update with invalid format
const response = await authRequest(
···
});
describe("PUT /api/user/name", () => {
-
serverTest("should update user name", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Update name
const newName = "Updated Name";
···
);
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify name updated
const meResponse = await authRequest(
···
expect(meData.name).toBe(newName);
});
-
serverTest("should reject missing name", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
const response = await authRequest(
`${BASE_URL}/api/user/name`,
···
});
describe("PUT /api/user/avatar", () => {
-
serverTest("should update user avatar", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Update avatar
const newAvatar = "👨‍💻";
···
});
});
-
describe("API Endpoints - Transcriptions", () => {
-
describe("GET /api/transcriptions/health", () => {
-
serverTest(
-
"should return transcription service health status",
-
async () => {
-
const response = await fetch(`${BASE_URL}/api/transcriptions/health`);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data).toHaveProperty("available");
-
expect(typeof data.available).toBe("boolean");
-
},
-
);
});
describe("GET /api/transcriptions", () => {
-
serverTest("should return user transcriptions", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Get transcriptions
const response = await authRequest(
···
expect(Array.isArray(data.jobs)).toBe(true);
});
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/transcriptions`);
expect(response.status).toBe(401);
···
});
describe("POST /api/transcriptions", () => {
-
serverTest("should upload audio file and start transcription", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Create a test audio file
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
···
},
);
-
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBeDefined();
expect(data.message).toContain("Upload successful");
});
-
serverTest("should reject non-audio files", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Try to upload non-audio file
const textBlob = new Blob(["text file"], { type: "text/plain" });
···
expect(response.status).toBe(400);
});
-
serverTest("should reject files exceeding size limit", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
// Create a file larger than 100MB (the actual limit)
const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
···
expect(data.error).toContain("File size must be less than");
});
-
serverTest("should require authentication", async () => {
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
const formData = new FormData();
formData.append("audio", audioBlob, "test.mp3");
···
let userId: number;
beforeEach(async () => {
-
if (!serverAvailable) return;
-
// Create admin user
-
const adminHash = await clientHashPassword(
-
TEST_ADMIN.email,
-
TEST_ADMIN.password,
-
);
-
const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_ADMIN.email,
-
password: adminHash,
-
name: TEST_ADMIN.name,
-
}),
-
});
-
adminCookie = extractSessionCookie(adminResponse);
// Manually set admin role in database
db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
TEST_ADMIN.email,
]);
// Create regular user
-
const userHash = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const userResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: userHash,
-
name: TEST_USER.name,
-
}),
-
});
-
userCookie = extractSessionCookie(userResponse);
// Get user ID
const userIdResult = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
.get(TEST_USER.email);
userId = userIdResult?.id;
});
describe("GET /api/admin/users", () => {
-
serverTest("should return all users for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
adminCookie,
···
expect(data.length).toBeGreaterThan(0);
});
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
userCookie,
···
expect(response.status).toBe(403);
});
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`);
expect(response.status).toBe(401);
···
});
describe("GET /api/admin/transcriptions", () => {
-
serverTest("should return all transcriptions for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
adminCookie,
···
expect(Array.isArray(data)).toBe(true);
});
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
userCookie,
···
});
describe("DELETE /api/admin/users/:id", () => {
-
serverTest("should delete user as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
adminCookie,
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
userCookie,
···
});
describe("PUT /api/admin/users/:id/role", () => {
-
serverTest("should update user role as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
adminCookie,
···
);
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify role updated
const meResponse = await authRequest(
···
expect(meData.role).toBe("admin");
});
-
serverTest("should reject invalid roles", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
adminCookie,
···
});
describe("GET /api/admin/users/:id/details", () => {
-
serverTest("should return user details for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
adminCookie,
···
expect(data).toHaveProperty("sessions");
});
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
userCookie,
···
});
describe("PUT /api/admin/users/:id/name", () => {
-
serverTest("should update user name as admin", async () => {
const newName = "Admin Updated Name";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
expect(data.success).toBe(true);
});
-
serverTest("should reject empty names", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
adminCookie,
···
});
describe("PUT /api/admin/users/:id/email", () => {
-
serverTest("should update user email as admin", async () => {
const newEmail = "newemail@admin.com";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
expect(data.success).toBe(true);
});
-
serverTest("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
adminCookie,
···
},
);
-
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
});
describe("GET /api/admin/users/:id/sessions", () => {
-
serverTest("should return user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
adminCookie,
···
});
describe("DELETE /api/admin/users/:id/sessions", () => {
-
serverTest("should delete all user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
adminCookie,
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify sessions are deleted
const verifyResponse = await authRequest(
···
let sessionCookie: string;
beforeEach(async () => {
-
if (!serverAvailable) return;
-
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
sessionCookie = extractSessionCookie(registerResponse);
});
describe("GET /api/passkeys", () => {
-
serverTest("should return user passkeys", async () => {
const response = await authRequest(
`${BASE_URL}/api/passkeys`,
sessionCookie,
···
expect(Array.isArray(data.passkeys)).toBe(true);
});
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/passkeys`);
expect(response.status).toBe(401);
···
});
describe("POST /api/passkeys/register/options", () => {
-
serverTest(
-
"should return registration options for authenticated user",
-
async () => {
-
const response = await authRequest(
-
`${BASE_URL}/api/passkeys/register/options`,
-
sessionCookie,
-
{
-
method: "POST",
-
},
-
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data).toHaveProperty("challenge");
-
expect(data).toHaveProperty("rp");
-
expect(data).toHaveProperty("user");
-
},
-
);
-
serverTest("should require authentication", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/register/options`,
{
···
});
describe("POST /api/passkeys/authenticate/options", () => {
-
serverTest("should return authentication options for email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
{
···
expect(data).toHaveProperty("challenge");
});
-
serverTest("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
{
···
expect,
test,
} from "bun:test";
+
import type { Subprocess } from "bun";
import { hashPasswordClient } from "./lib/client-auth";
+
// Test server configuration
const TEST_PORT = 3001;
const BASE_URL = `http://localhost:${TEST_PORT}`;
+
const TEST_DB_PATH = "./thistle.test.db";
+
// Test server process
+
let serverProcess: Subprocess | null = null;
beforeAll(async () => {
+
// Clean up any existing test database
try {
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
} catch {
+
// Ignore if doesn't exist
}
+
+
// Start test server as subprocess
+
serverProcess = Bun.spawn(["bun", "run", "src/index.ts"], {
+
env: {
+
...process.env,
+
NODE_ENV: "test",
+
PORT: TEST_PORT.toString(),
+
SKIP_EMAILS: "true",
+
SKIP_POLAR_SYNC: "true",
+
// Dummy env vars to pass startup validation (won't be used due to SKIP_EMAILS/SKIP_POLAR_SYNC)
+
MAILCHANNELS_API_KEY: "test-key",
+
DKIM_PRIVATE_KEY: "test-key",
+
LLM_API_KEY: "test-key",
+
LLM_API_BASE_URL: "https://test.com",
+
LLM_MODEL: "test-model",
+
POLAR_ACCESS_TOKEN: "test-token",
+
POLAR_ORGANIZATION_ID: "test-org",
+
POLAR_PRODUCT_ID: "test-product",
+
POLAR_SUCCESS_URL: "http://localhost:3001/success",
+
POLAR_WEBHOOK_SECRET: "test-webhook-secret",
+
ORIGIN: "http://localhost:3001",
+
},
+
stdout: "pipe",
+
stderr: "pipe",
+
});
+
+
// Log server output for debugging
+
const stdoutReader = serverProcess.stdout.getReader();
+
const stderrReader = serverProcess.stderr.getReader();
+
const decoder = new TextDecoder();
+
+
(async () => {
+
try {
+
while (true) {
+
const { value, done } = await stdoutReader.read();
+
if (done) break;
+
const text = decoder.decode(value);
+
console.log("[SERVER OUT]", text.trim());
+
}
+
} catch {}
+
})();
+
+
(async () => {
+
try {
+
while (true) {
+
const { value, done } = await stderrReader.read();
+
if (done) break;
+
const text = decoder.decode(value);
+
console.error("[SERVER ERR]", text.trim());
+
}
+
} catch {}
+
})();
+
+
// Wait for server to be ready
+
let retries = 30;
+
let ready = false;
+
while (retries > 0 && !ready) {
+
try {
+
const response = await fetch(`${BASE_URL}/api/health`, {
+
signal: AbortSignal.timeout(1000),
+
});
+
if (response.ok) {
+
ready = true;
+
break;
+
}
+
} catch {
+
// Server not ready yet
+
}
+
await new Promise((resolve) => setTimeout(resolve, 500));
+
retries--;
+
}
+
+
if (!ready) {
+
throw new Error("Test server failed to start within 15 seconds");
+
}
+
+
console.log(`✓ Test server running on port ${TEST_PORT}`);
+
});
+
+
afterAll(async () => {
+
// Kill test server
+
if (serverProcess) {
+
serverProcess.kill();
+
await new Promise((resolve) => setTimeout(resolve, 1000));
+
}
+
+
// Clean up test database
+
try {
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
+
} catch {
+
// Ignore if doesn't exist
+
}
+
+
console.log("✓ Test server stopped and test database cleaned up");
+
});
+
+
// Clear database between each test
+
beforeEach(async () => {
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
+
// Delete all data from tables (preserve schema)
+
db.run("DELETE FROM rate_limit_attempts");
+
db.run("DELETE FROM email_change_tokens");
+
db.run("DELETE FROM password_reset_tokens");
+
db.run("DELETE FROM email_verification_tokens");
+
db.run("DELETE FROM passkeys");
+
db.run("DELETE FROM sessions");
+
db.run("DELETE FROM subscriptions");
+
db.run("DELETE FROM transcriptions");
+
db.run("DELETE FROM class_members");
+
db.run("DELETE FROM meeting_times");
+
db.run("DELETE FROM classes");
+
db.run("DELETE FROM class_waitlist");
+
db.run("DELETE FROM users WHERE id != 0"); // Keep ghost user
+
+
db.close();
});
// Test user credentials
···
});
}
+
// Helper to register a user, verify email, and get session via login
+
async function registerAndLogin(user: {
+
email: string;
+
password: string;
+
name?: string;
+
}): Promise<string> {
+
const hashedPassword = await clientHashPassword(user.email, user.password);
+
+
// Register the user
+
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: user.email,
+
password: hashedPassword,
+
name: user.name || "Test User",
+
}),
+
});
+
+
if (registerResponse.status !== 201) {
+
const error = await registerResponse.json();
+
throw new Error(`Registration failed: ${JSON.stringify(error)}`);
+
}
+
+
const registerData = await registerResponse.json();
+
const userId = registerData.user.id;
+
+
// Mark email as verified directly in the database (test mode)
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
+
db.close();
+
// Now login to get a session
+
const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: user.email,
+
password: hashedPassword,
+
}),
+
});
+
if (loginResponse.status !== 200) {
+
const error = await loginResponse.json();
+
throw new Error(`Login failed: ${JSON.stringify(error)}`);
}
+
return extractSessionCookie(loginResponse);
+
}
+
+
// Helper to add active subscription to a user
+
function addSubscription(userEmail: string): void {
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
const user = db
+
.query("SELECT id FROM users WHERE email = ?")
+
.get(userEmail) as { id: number };
+
if (!user) {
+
db.close();
+
throw new Error(`User ${userEmail} not found`);
}
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
[`test-sub-${user.id}`, user.id, `test-customer-${user.id}`, "active"],
+
);
+
db.close();
}
+
// All tests run against a fresh database, no cleanup needed
+
describe("API Endpoints - Authentication", () => {
describe("POST /api/auth/register", () => {
+
test("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
···
}),
});
+
if (response.status !== 201) {
+
const error = await response.json();
+
console.error("Registration failed:", response.status, error);
+
}
+
+
expect(response.status).toBe(201);
+
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
+
expect(data.email_verification_required).toBe(true);
});
+
test("should reject registration with missing email", async () => {
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
});
+
test("should reject registration with invalid password format", async () => {
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_USER.email,
+
password: "short",
}),
});
expect(response.status).toBe(400);
const data = await response.json();
+
expect(data.error).toBe("Invalid password format");
});
+
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
);
+
+
// First registration
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
···
}),
});
+
// Duplicate registration
+
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_USER.email,
password: hashedPassword,
+
name: TEST_USER.name,
}),
});
+
expect(response.status).toBe(409);
const data = await response.json();
+
expect(data.error).toBe("Email already registered");
});
+
test("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
+
"ratelimit@example.com",
+
"password",
);
+
+
// First registration succeeds
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+
email: "ratelimit@example.com",
password: hashedPassword,
}),
});
+
// Try to register same email 10 more times (will fail with 400 but count toward rate limit)
+
// Rate limit is 5 per 30 min from same IP
let rateLimitHit = false;
+
for (let i = 0; i < 10; i++) {
+
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+
email: "ratelimit@example.com",
password: hashedPassword,
}),
});
···
});
});
+
describe("POST /api/auth/login", () => {
+
test("should login successfully with valid credentials", async () => {
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Try to delete own current session
const response = await authRequest(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
+
test("should delete user account", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Delete account
const response = await authRequest(
···
},
);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
method: "DELETE",
});
···
});
describe("PUT /api/user/email", () => {
+
test("should update user email", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
// Update email - this creates a token but doesn't change email yet
const newEmail = "newemail@example.com";
const response = await authRequest(
`${BASE_URL}/api/user/email`,
···
const data = await response.json();
expect(data.success).toBe(true);
+
// Manually complete the email change in the database (simulating verification)
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
const tokenData = db
+
.query(
+
"SELECT user_id, new_email FROM email_change_tokens ORDER BY created_at DESC LIMIT 1",
+
)
+
.get() as { user_id: number; new_email: string };
+
db.run("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", [
+
tokenData.new_email,
+
tokenData.user_id,
+
]);
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [
+
tokenData.user_id,
+
]);
+
db.close();
+
// Verify email updated
const meResponse = await authRequest(
`${BASE_URL}/api/auth/me`,
···
expect(meData.email).toBe(newEmail);
});
+
test("should reject duplicate email", async () => {
// Register two users
+
await registerAndLogin(TEST_USER);
+
const user2Cookie = await registerAndLogin(TEST_USER_2);
// Try to update user2's email to user1's email
const response = await authRequest(
···
},
);
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
});
describe("PUT /api/user/password", () => {
+
test("should update user password", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update password
const newPassword = await clientHashPassword(
···
expect(loginResponse.status).toBe(200);
});
+
test("should reject invalid password format", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Try to update with invalid format
const response = await authRequest(
···
});
describe("PUT /api/user/name", () => {
+
test("should update user name", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update name
const newName = "Updated Name";
···
);
expect(response.status).toBe(200);
// Verify name updated
const meResponse = await authRequest(
···
expect(meData.name).toBe(newName);
});
+
test("should reject missing name", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
const response = await authRequest(
`${BASE_URL}/api/user/name`,
···
});
describe("PUT /api/user/avatar", () => {
+
test("should update user avatar", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update avatar
const newAvatar = "👨‍💻";
···
});
});
+
describe("API Endpoints - Health", () => {
+
describe("GET /api/health", () => {
+
test("should return service health status with details", async () => {
+
const response = await fetch(`${BASE_URL}/api/health`);
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data).toHaveProperty("status");
+
expect(data).toHaveProperty("timestamp");
+
expect(data).toHaveProperty("services");
+
expect(data.services).toHaveProperty("database");
+
expect(data.services).toHaveProperty("whisper");
+
expect(data.services).toHaveProperty("storage");
+
});
});
+
});
+
describe("API Endpoints - Transcriptions", () => {
describe("GET /api/transcriptions", () => {
+
test("should return user transcriptions", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Get transcriptions
const response = await authRequest(
···
expect(Array.isArray(data.jobs)).toBe(true);
});
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/transcriptions`);
expect(response.status).toBe(401);
···
});
describe("POST /api/transcriptions", () => {
+
test("should upload audio file and start transcription", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Create a test audio file
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
···
},
);
+
expect(response.status).toBe(201);
const data = await response.json();
expect(data.id).toBeDefined();
expect(data.message).toContain("Upload successful");
});
+
test("should reject non-audio files", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Try to upload non-audio file
const textBlob = new Blob(["text file"], { type: "text/plain" });
···
expect(response.status).toBe(400);
});
+
test("should reject files exceeding size limit", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Create a file larger than 100MB (the actual limit)
const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
···
expect(data.error).toContain("File size must be less than");
});
+
test("should require authentication", async () => {
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
const formData = new FormData();
formData.append("audio", audioBlob, "test.mp3");
···
let userId: number;
beforeEach(async () => {
// Create admin user
+
adminCookie = await registerAndLogin(TEST_ADMIN);
// Manually set admin role in database
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
TEST_ADMIN.email,
]);
// Create regular user
+
userCookie = await registerAndLogin(TEST_USER);
// Get user ID
const userIdResult = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
.get(TEST_USER.email);
userId = userIdResult?.id;
+
+
db.close();
});
describe("GET /api/admin/users", () => {
+
test("should return all users for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
adminCookie,
···
expect(data.length).toBeGreaterThan(0);
});
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
userCookie,
···
expect(response.status).toBe(403);
});
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`);
expect(response.status).toBe(401);
···
});
describe("GET /api/admin/transcriptions", () => {
+
test("should return all transcriptions for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
adminCookie,
···
expect(Array.isArray(data)).toBe(true);
});
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
userCookie,
···
});
describe("DELETE /api/admin/users/:id", () => {
+
test("should delete user as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
adminCookie,
···
},
);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
userCookie,
···
});
describe("PUT /api/admin/users/:id/role", () => {
+
test("should update user role as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
adminCookie,
···
);
expect(response.status).toBe(200);
// Verify role updated
const meResponse = await authRequest(
···
expect(meData.role).toBe("admin");
});
+
test("should reject invalid roles", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
adminCookie,
···
});
describe("GET /api/admin/users/:id/details", () => {
+
test("should return user details for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
adminCookie,
···
expect(data).toHaveProperty("sessions");
});
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
userCookie,
···
});
describe("PUT /api/admin/users/:id/name", () => {
+
test("should update user name as admin", async () => {
const newName = "Admin Updated Name";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
expect(data.success).toBe(true);
});
+
test("should reject empty names", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
adminCookie,
···
});
describe("PUT /api/admin/users/:id/email", () => {
+
test("should update user email as admin", async () => {
const newEmail = "newemail@admin.com";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
expect(data.success).toBe(true);
});
+
test("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
adminCookie,
···
},
);
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
});
describe("GET /api/admin/users/:id/sessions", () => {
+
test("should return user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
adminCookie,
···
});
describe("DELETE /api/admin/users/:id/sessions", () => {
+
test("should delete all user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
adminCookie,
···
},
);
+
expect(response.status).toBe(204);
// Verify sessions are deleted
const verifyResponse = await authRequest(
···
let sessionCookie: string;
beforeEach(async () => {
+
// Register and login
+
sessionCookie = await registerAndLogin(TEST_USER);
});
describe("GET /api/passkeys", () => {
+
test("should return user passkeys", async () => {
const response = await authRequest(
`${BASE_URL}/api/passkeys`,
sessionCookie,
···
expect(Array.isArray(data.passkeys)).toBe(true);
});
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/passkeys`);
expect(response.status).toBe(401);
···
});
describe("POST /api/passkeys/register/options", () => {
+
test("should return registration options for authenticated user", async () => {
+
const response = await authRequest(
+
`${BASE_URL}/api/passkeys/register/options`,
+
sessionCookie,
+
{
+
method: "POST",
+
},
+
);
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data).toHaveProperty("challenge");
+
expect(data).toHaveProperty("rp");
+
expect(data).toHaveProperty("user");
+
});
+
test("should require authentication", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/register/options`,
{
···
});
describe("POST /api/passkeys/authenticate/options", () => {
+
test("should return authentication options for email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
{
···
expect(data).toHaveProperty("challenge");
});
+
test("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
{
+1641 -488
src/index.ts
···
import {
authenticateUser,
cleanupExpiredSessions,
createSession,
createUser,
deleteAllUserSessions,
···
getUserByEmail,
getUserBySession,
getUserSessionsForUser,
type UserRole,
updateUserAvatar,
updateUserEmail,
···
updateUserName,
updateUserPassword,
updateUserRole,
-
createEmailVerificationToken,
verifyEmailToken,
-
verifyEmailCode,
-
isEmailVerified,
-
getVerificationCodeSentAt,
-
createPasswordResetToken,
verifyPasswordResetToken,
-
consumePasswordResetToken,
} from "./lib/auth";
import {
addToWaitlist,
···
getClassById,
getClassesForUser,
getClassMembers,
getMeetingTimesForClass,
getTranscriptionsForClass,
isUserEnrolledInClass,
joinClass,
removeUserFromClass,
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
} from "./lib/classes";
import { AuthErrors, handleError, ValidationErrors } from "./lib/errors";
import {
hasActiveSubscription,
···
verifyAndAuthenticatePasskey,
verifyAndCreatePasskey,
} from "./lib/passkey";
-
import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
import { getTranscriptVTT } from "./lib/transcript-storage";
import {
MAX_FILE_SIZE,
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
-
import { sendEmail } from "./lib/email";
import {
-
verifyEmailTemplate,
-
passwordResetTemplate,
-
} from "./lib/email-templates";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
···
import settingsHTML from "./pages/settings.html";
import transcribeHTML from "./pages/transcribe.html";
// Environment variables
const WHISPER_SERVICE_URL =
process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
···
transcriptionEvents,
);
-
// Clean up expired sessions every hour
-
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
// Helper function to sync user subscriptions from Polar
async function syncUserSubscriptionsFromPolar(
userId: number,
email: string,
): Promise<void> {
try {
const { polar } = await import("./lib/polar");
-
// Search for customer by email
const customers = await polar.customers.list({
-
organizationId: process.env.POLAR_ORGANIZATION_ID,
query: email,
});
···
customerId: customer.id,
});
-
if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
return;
}
-
// Update each subscription in the database
-
for (const subscription of subscriptions.result.items) {
db.run(
`INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
···
}
console.log(
-
`[Sync] Linked ${subscriptions.result.items.length} subscription(s) to user ${userId} (${email})`,
);
} catch (error) {
console.error(
···
// Don't throw - registration should succeed even if sync fails
}
}
-
// Sync with Whisper DB on startup
try {
···
}
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
-
setInterval(
async () => {
try {
await whisperService.syncWithWhisper();
···
5 * 60 * 1000,
);
-
// Clean up stale files daily
-
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
const server = Bun.serve({
-
port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
···
{ status: 400 },
);
}
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
-
{ error: "Invalid password format" },
{ status: 400 },
);
}
const user = await createUser(email, password, name);
-
// Send verification email - MUST succeed for registration to complete
const { code, token, sentAt } = createEmailVerificationToken(user.id);
-
try {
await sendEmail({
to: user.email,
···
} catch (err) {
console.error("[Email] Failed to send verification email:", err);
// Rollback user creation - direct DB delete since user was just created
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]);
db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
db.run("DELETE FROM users WHERE id = ?", [user.id]);
return Response.json(
-
{ error: "Failed to send verification email. Please try again later." },
{ status: 500 },
);
}
-
// Attempt to sync existing Polar subscriptions (after email succeeds)
syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
// Silent fail - don't block registration
});
-
// Clear rate limits on successful registration
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("register", email, ipAddress);
-
// Return success but indicate email verification is needed
// Don't create session yet - they need to verify first
return Response.json(
-
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: sentAt,
},
-
{ status: 200 },
);
} catch (err: unknown) {
const error = err as { message?: string };
if (error.message?.includes("UNIQUE constraint failed")) {
return Response.json(
{ error: "Email already registered" },
-
{ status: 400 },
);
}
console.error("[Auth] Registration error:", err);
···
});
if (rateLimitError) return rateLimitError;
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
-
{ error: "Invalid password format" },
{ status: 400 },
);
}
···
{ status: 401 },
);
}
-
// Clear rate limits on successful authentication
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("login", email, ipAddress);
-
// Check if email is verified
if (!isEmailVerified(user.id)) {
let codeSentAt = getVerificationCodeSentAt(user.id);
-
// If no verification code exists, auto-send one
if (!codeSentAt) {
-
const { code, token, sentAt } = createEmailVerificationToken(user.id);
codeSentAt = sentAt;
-
try {
await sendEmail({
to: user.email,
···
}),
});
} catch (err) {
-
console.error("[Email] Failed to send verification email on login:", err);
// Don't fail login - just return null timestamp so client can try resend
codeSentAt = null;
}
}
-
return Response.json(
-
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: codeSentAt,
···
{ status: 200 },
);
}
-
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
···
return new Response(null, {
status: 302,
headers: {
-
"Location": "/classes",
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
},
});
···
// Get user by email
const user = getUserByEmail(email);
if (!user) {
-
return Response.json(
-
{ error: "User not found" },
-
{ status: 404 },
-
);
}
// Check if already verified
···
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
-
{
message: "Email verified successfully",
email_verified: true,
user: { id: user.id, email: user.email },
···
POST: async (req) => {
try {
const user = requireAuth(req);
-
// Rate limiting
const rateLimitError = enforceRateLimit(req, "resend-verification", {
account: { max: 3, windowSeconds: 60 * 60, email: user.email },
···
}),
});
-
return Response.json({ message: "Verification email sent" });
} catch (error) {
return handleError(error);
}
···
}
// Rate limiting by email
-
const rateLimitError = enforceRateLimit(req, "resend-verification-code", {
-
account: { max: 3, windowSeconds: 5 * 60, email },
-
});
if (rateLimitError) return rateLimitError;
// Get user by email
const user = getUserByEmail(email);
if (!user) {
// Don't reveal if user exists
-
return Response.json({ message: "If an account exists with that email, a verification code has been sent" });
}
// Check if already verified
···
}),
});
-
return Response.json({
message: "Verification code sent",
verification_code_sent_at: sentAt,
});
···
// Always return success to prevent email enumeration
const user = getUserByEmail(email);
if (user) {
-
const origin =
-
req.headers.get("origin") || "http://localhost:3000";
const resetToken = createPasswordResetToken(user.id);
const resetLink = `${origin}/reset-password?token=${resetToken}`;
···
}
return Response.json({
message:
"If an account exists with that email, a password reset link has been sent",
});
···
},
},
"/api/auth/reset-password": {
POST: async (req) => {
try {
const body = await req.json();
···
}
// Validate password format (client-side hashed PBKDF2)
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
-
{ error: "Invalid password format" },
{ status: 400 },
);
}
···
await updateUserPassword(userId, password);
consumePasswordResetToken(token);
-
return Response.json({ message: "Password reset successfully" });
} catch (error) {
console.error("[Email] Reset password error:", error);
return Response.json(
···
},
"/api/auth/me": {
GET: (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
// Check subscription status
-
const subscription = db
-
.query<{ status: string }, [number]>(
-
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
-
)
-
.get(user.id);
-
// Get notification preferences
-
const prefs = db
-
.query<{ email_notifications_enabled: number }, [number]>(
-
"SELECT email_notifications_enabled FROM users WHERE id = ?",
-
)
-
.get(user.id);
-
return Response.json({
-
email: user.email,
-
name: user.name,
-
avatar: user.avatar,
-
created_at: user.created_at,
-
role: user.role,
-
has_subscription: !!subscription,
-
email_verified: isEmailVerified(user.id),
-
email_notifications_enabled: prefs?.email_notifications_enabled === 1,
-
});
},
},
"/api/passkeys/register/options": {
POST: async (req) => {
try {
const user = requireAuth(req);
const options = await createRegistrationOptions(user);
return Response.json(options);
} catch (err) {
···
POST: async (req) => {
try {
const _user = requireAuth(req);
const body = await req.json();
const { response: credentialResponse, challenge, name } = body;
···
"/api/passkeys/authenticate/options": {
POST: async (req) => {
try {
const body = await req.json();
const { email } = body;
···
"/api/passkeys/authenticate/verify": {
POST: async (req) => {
try {
const body = await req.json();
const { response: credentialResponse, challenge } = body;
···
PUT: async (req) => {
try {
const user = requireAuth(req);
const body = await req.json();
const { name } = body;
const passkeyId = req.params.id;
···
}
updatePasskeyName(passkeyId, user.id, name);
-
return Response.json({ success: true });
} catch (err) {
return handleError(err);
}
···
DELETE: async (req) => {
try {
const user = requireAuth(req);
const passkeyId = req.params.id;
deletePasskey(passkeyId, user.id);
-
return Response.json({ success: true });
} catch (err) {
return handleError(err);
}
···
},
"/api/sessions": {
GET: (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const sessions = getUserSessionsForUser(user.id);
-
return Response.json({
-
sessions: sessions.map((s) => ({
-
id: s.id,
-
ip_address: s.ip_address,
-
user_agent: s.user_agent,
-
created_at: s.created_at,
-
expires_at: s.expires_at,
-
is_current: s.id === sessionId,
-
})),
-
});
},
DELETE: async (req) => {
-
const currentSessionId = getSessionFromRequest(req);
-
if (!currentSessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(currentSessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const body = await req.json();
-
const targetSessionId = body.sessionId;
-
if (!targetSessionId) {
-
return Response.json(
-
{ error: "Session ID required" },
-
{ status: 400 },
-
);
-
}
-
// Prevent deleting current session
-
if (targetSessionId === currentSessionId) {
-
return Response.json(
-
{ error: "Cannot kill current session. Use logout instead." },
-
{ status: 400 },
-
);
-
}
-
// Verify the session belongs to the user
-
const targetSession = getSession(targetSessionId);
-
if (!targetSession || targetSession.user_id !== user.id) {
-
return Response.json({ error: "Session not found" }, { status: 404 });
}
-
deleteSession(targetSessionId);
-
return Response.json({ success: true });
},
},
"/api/user": {
DELETE: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "delete-user", {
-
ip: { max: 3, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
-
await deleteUser(user.id);
-
return Response.json(
-
{ success: true },
-
{
headers: {
"Set-Cookie":
"session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
},
-
},
-
);
},
},
"/api/user/email": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-email", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { email } = body;
-
if (!email) {
-
return Response.json({ error: "Email required" }, { status: 400 });
-
}
-
try {
-
updateUserEmail(user.id, email);
-
return Response.json({ success: true });
-
} catch (err: unknown) {
-
const error = err as { message?: string };
-
if (error.message?.includes("UNIQUE constraint failed")) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
);
}
-
return Response.json(
-
{ error: "Failed to update email" },
-
{ status: 500 },
-
);
}
},
},
-
"/api/user/password": {
-
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-password", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { password } = body;
-
if (!password) {
-
return Response.json({ error: "Password required" }, { status: 400 });
-
}
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
-
return Response.json(
-
{ error: "Invalid password format" },
-
{ status: 400 },
);
}
try {
-
await updateUserPassword(user.id, password);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update password" },
-
{ status: 500 },
-
);
}
},
},
"/api/user/name": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const body = await req.json();
-
const { name } = body;
-
if (!name) {
-
return Response.json({ error: "Name required" }, { status: 400 });
-
}
try {
-
updateUserName(user.id, name);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update name" },
-
{ status: 500 },
-
);
}
},
},
"/api/user/avatar": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const body = await req.json();
-
const { avatar } = body;
-
if (!avatar) {
-
return Response.json({ error: "Avatar required" }, { status: 400 });
-
}
try {
-
updateUserAvatar(user.id, avatar);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update avatar" },
-
{ status: 500 },
-
);
}
},
},
"/api/user/notifications": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const body = await req.json();
-
const { email_notifications_enabled } = body;
-
if (typeof email_notifications_enabled !== "boolean") {
-
return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 });
-
}
try {
-
db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update notification settings" },
-
{ status: 500 },
-
);
-
}
-
},
-
},
-
"/api/billing/checkout": {
-
POST: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
try {
-
const { polar } = await import("./lib/polar");
-
const productId = process.env.POLAR_PRODUCT_ID;
-
if (!productId) {
return Response.json(
-
{ error: "Product not configured" },
-
{ status: 500 },
);
}
-
-
const successUrl = process.env.POLAR_SUCCESS_URL;
-
if (!successUrl) {
return Response.json(
-
{ error: "Success URL not configured" },
{ status: 500 },
);
}
const checkout = await polar.checkouts.create({
products: [productId],
···
});
return Response.json({ url: checkout.url });
-
} catch (error) {
-
console.error("Failed to create checkout:", error);
-
return Response.json(
-
{ error: "Failed to create checkout session" },
-
{ status: 500 },
-
);
}
},
},
"/api/billing/subscription": {
GET: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
try {
// Get subscription from database
const subscription = db
.query<
···
}
return Response.json({ subscription });
-
} catch (error) {
-
console.error("Failed to fetch subscription:", error);
-
return Response.json(
-
{ error: "Failed to fetch subscription" },
-
{ status: 500 },
-
);
}
},
},
"/api/billing/portal": {
POST: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
try {
const { polar } = await import("./lib/polar");
// Get subscription to find customer ID
···
});
return Response.json({ url: session.customerPortalUrl });
-
} catch (error) {
-
console.error("Failed to create portal session:", error);
-
return Response.json(
-
{ error: "Failed to create portal session" },
-
{ status: 500 },
-
);
}
},
},
"/api/webhooks/polar": {
POST: async (req) => {
-
try {
-
const { validateEvent } = await import("@polar-sh/sdk/webhooks");
-
// Get raw body as string
-
const rawBody = await req.text();
-
const headers = Object.fromEntries(req.headers.entries());
-
// Validate webhook signature
-
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
-
if (!webhookSecret) {
-
console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured");
-
return Response.json(
-
{ error: "Webhook secret not configured" },
-
{ status: 500 },
-
);
-
}
-
-
const event = validateEvent(rawBody, headers, webhookSecret);
-
console.log(`[Webhook] Received event: ${event.type}`);
-
// Handle different event types
switch (event.type) {
case "subscription.updated": {
const { id, status, customerId, metadata } = event.data;
···
return Response.json({ received: true });
} catch (error) {
-
console.error("[Webhook] Error processing webhook:", error);
-
return Response.json(
-
{ error: "Webhook processing failed" },
-
{ status: 400 },
-
);
}
},
},
···
const transcriptionId = req.params.id;
// Verify ownership
const transcription = db
-
.query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>(
"SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
)
.get(transcriptionId);
-
if (!transcription) {
return Response.json(
{ error: "Transcription not found" },
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
throw AuthErrors.subscriptionRequired();
}
// Event-driven SSE stream with reconnection support
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let isClosed = false;
let lastEventId = Math.floor(Date.now() / 1000);
···
current?.status === "failed"
) {
isClosed = true;
controller.close();
return;
}
···
isClosed = true;
clearInterval(heartbeatInterval);
transcriptionEvents.off(transcriptionId, updateHandler);
controller.close();
}
};
···
isClosed = true;
clearInterval(heartbeatInterval);
transcriptionEvents.off(transcriptionId, updateHandler);
};
},
});
···
}
},
},
-
"/api/transcriptions/health": {
GET: async () => {
-
const isHealthy = await whisperService.checkHealth();
-
return Response.json({ available: isHealthy });
},
},
"/api/transcriptions/:id": {
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
throw AuthErrors.subscriptionRequired();
}
if (transcription.status !== "completed") {
return Response.json(
{ error: "Transcription not completed yet" },
-
{ status: 400 },
);
}
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
throw AuthErrors.subscriptionRequired();
}
···
}
},
},
-
"/api/transcriptions": {
GET: async (req) => {
try {
-
const user = requireSubscription(req);
-
const transcriptions = db
.query<
-
{
-
id: string;
-
filename: string;
-
original_filename: string;
-
class_id: string | null;
-
status: string;
-
progress: number;
-
created_at: number;
-
},
-
[number]
>(
-
"SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
)
-
.all(user.id);
// Load transcripts from files for completed jobs
const jobs = await Promise.all(
···
}),
);
-
return Response.json({ jobs });
} catch (error) {
return handleError(error);
}
···
try {
const user = requireSubscription(req);
const formData = await req.formData();
const file = formData.get("audio") as File;
const classId = formData.get("class_id") as string | null;
-
const meetingTimeId = formData.get("meeting_time_id") as
| string
| null;
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
-
// Create database record
db.run(
-
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
transcriptionId,
user.id,
classId,
-
meetingTimeId,
filename,
file.name,
"pending",
],
);
// Don't auto-start transcription - admin will select recordings
// whisperService.startTranscription(transcriptionId, filename);
-
return Response.json({
-
id: transcriptionId,
-
message: "Upload successful",
-
});
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
requireAdmin(req);
-
const transcriptions = getAllTranscriptions();
-
return Response.json(transcriptions);
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
requireAdmin(req);
-
const users = getAllUsersWithStats();
-
return Response.json(users);
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const id = req.params.id;
deleteWaitlistEntry(id);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
await deleteUser(userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
.get(userId);
if (!user) {
-
return Response.json(
-
{ error: "User not found" },
-
{ status: 404 },
-
);
}
try {
···
}
},
},
-
"/api/admin/users/:id/password": {
-
PUT: async (req) => {
try {
requireAdmin(req);
const userId = Number.parseInt(req.params.id, 10);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
-
const body = await req.json();
-
const { password } = body as { password: string };
-
if (!password || password.length < 8) {
-
return Response.json(
-
{ error: "Password must be at least 8 characters" },
-
{ status: 400 },
-
);
}
-
await updateUserPassword(userId, password);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
},
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
const body = await req.json();
const { name } = body as { name: string };
-
if (!name || name.trim().length === 0) {
return Response.json(
-
{ error: "Name cannot be empty" },
{ status: 400 },
);
}
···
}
const body = await req.json();
-
const { email } = body as { email: string };
-
if (!email || !email.includes("@")) {
return Response.json(
-
{ error: "Invalid email address" },
{ status: 400 },
);
}
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
);
}
-
updateUserEmailAddress(userId, email);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
}
deleteAllUserSessions(userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
);
}
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
const user = requireAuth(req);
-
const classes = getClassesForUser(user.id, user.role === "admin");
-
// Group by semester/year
const grouped: Record<
string,
Array<{
···
}>
> = {};
-
for (const cls of classes) {
const key = `${cls.semester} ${cls.year}`;
if (!grouped[key]) {
grouped[key] = [];
···
});
}
-
return Response.json({ classes: grouped });
} catch (error) {
return handleError(error);
}
···
meeting_times,
} = body;
-
if (!course_code || !name || !professor || !semester || !year) {
return Response.json(
-
{ error: "Missing required fields" },
{ status: 400 },
);
}
···
semester,
year,
meeting_times,
});
-
return Response.json(newClass);
} catch (error) {
return handleError(error);
}
···
.all(user.id)
.map((row) => row.class_id);
-
// Add is_enrolled flag to each class
const classesWithEnrollment = classes.map((cls) => ({
...cls,
is_enrolled: enrolledClassIds.includes(cls.id),
}));
return Response.json({ classes: classesWithEnrollment });
···
const user = requireAuth(req);
const body = await req.json();
const classId = body.class_id;
-
if (!classId || typeof classId !== "string") {
return Response.json(
-
{ error: "Class ID required" },
{ status: 400 },
);
}
-
const result = joinClass(classId, user.id);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
meetingTimes,
} = body;
-
if (!courseCode || !courseName || !professor || !semester || !year) {
return Response.json(
-
{ error: "Missing required fields" },
{ status: 400 },
);
}
···
meetingTimes || null,
);
-
return Response.json({ success: true, id });
} catch (error) {
return handleError(error);
}
···
}
const meetingTimes = getMeetingTimesForClass(classId);
const transcriptions = getTranscriptionsForClass(classId);
return Response.json({
class: classInfo,
meetingTimes,
transcriptions,
});
} catch (error) {
···
requireAdmin(req);
const classId = req.params.id;
deleteClass(classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
);
}
toggleClassArchive(classId, archived);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Email required" }, { status: 400 });
}
const user = getUserByEmail(email);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
enrollUserInClass(user.id, classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
removeUserFromClass(userId, classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Label required" }, { status: 400 });
}
const meetingTime = createMeetingTime(classId, label);
-
return Response.json(meetingTime);
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Label required" }, { status: 400 });
}
updateMeetingTime(meetingId, label);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const meetingId = req.params.id;
deleteMeetingTime(meetingId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
try {
requireAdmin(req);
const transcriptId = req.params.id;
// Update status to 'selected' and start transcription
db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
···
transcriptId,
]);
-
// Get filename to start transcription
-
const transcription = db
-
.query<{ filename: string }, [string]>(
-
"SELECT filename FROM transcriptions WHERE id = ?",
-
)
-
.get(transcriptId);
-
if (transcription) {
-
whisperService.startTranscription(
-
transcriptId,
-
transcription.filename,
-
);
-
}
-
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
},
},
},
-
development: {
-
hmr: true,
-
console: true,
},
});
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
···
import {
authenticateUser,
cleanupExpiredSessions,
+
consumeEmailChangeToken,
+
consumePasswordResetToken,
+
createEmailChangeToken,
+
createEmailVerificationToken,
+
createPasswordResetToken,
createSession,
createUser,
deleteAllUserSessions,
···
getUserByEmail,
getUserBySession,
getUserSessionsForUser,
+
getVerificationCodeSentAt,
+
isEmailVerified,
type UserRole,
updateUserAvatar,
updateUserEmail,
···
updateUserName,
updateUserPassword,
updateUserRole,
+
verifyEmailChangeToken,
+
verifyEmailCode,
verifyEmailToken,
verifyPasswordResetToken,
} from "./lib/auth";
import {
addToWaitlist,
···
getClassById,
getClassesForUser,
getClassMembers,
+
getClassSections,
+
getMeetingById,
getMeetingTimesForClass,
getTranscriptionsForClass,
+
getUserSection,
isUserEnrolledInClass,
joinClass,
removeUserFromClass,
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
+
createClassSection,
} from "./lib/classes";
+
import { sendEmail } from "./lib/email";
+
import {
+
emailChangeTemplate,
+
passwordResetTemplate,
+
verifyEmailTemplate,
+
} from "./lib/email-templates";
import { AuthErrors, handleError, ValidationErrors } from "./lib/errors";
import {
hasActiveSubscription,
···
verifyAndAuthenticatePasskey,
verifyAndCreatePasskey,
} from "./lib/passkey";
+
import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit";
import { getTranscriptVTT } from "./lib/transcript-storage";
import {
MAX_FILE_SIZE,
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
+
import {
+
findMatchingMeetingTime,
+
getDayName,
+
} from "./lib/audio-metadata";
+
import {
+
checkAutoSubmit,
+
deletePendingRecording,
+
getEnrolledUserCount,
+
getPendingRecordings,
+
getUserVoteForMeeting,
+
markAsAutoSubmitted,
+
removeVote,
+
voteForRecording,
+
} from "./lib/voting";
import {
+
validateClassId,
+
validateCourseCode,
+
validateCourseName,
+
validateEmail,
+
validateName,
+
validatePasswordHash,
+
validateSemester,
+
validateYear,
+
} from "./lib/validation";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
···
import settingsHTML from "./pages/settings.html";
import transcribeHTML from "./pages/transcribe.html";
+
// Validate required environment variables at startup
+
function validateEnvVars() {
+
const required = [
+
"POLAR_ORGANIZATION_ID",
+
"POLAR_PRODUCT_ID",
+
"POLAR_SUCCESS_URL",
+
"POLAR_WEBHOOK_SECRET",
+
"MAILCHANNELS_API_KEY",
+
"DKIM_PRIVATE_KEY",
+
"LLM_API_KEY",
+
"LLM_API_BASE_URL",
+
"LLM_MODEL",
+
];
+
+
const missing = required.filter((key) => !process.env[key]);
+
+
if (missing.length > 0) {
+
console.error(
+
`[Startup] Missing required environment variables: ${missing.join(", ")}`,
+
);
+
console.error("[Startup] Please check your .env file");
+
process.exit(1);
+
}
+
+
// Validate ORIGIN is set for production
+
if (!process.env.ORIGIN) {
+
console.warn(
+
"[Startup] ORIGIN not set, defaulting to http://localhost:3000",
+
);
+
console.warn("[Startup] Set ORIGIN in production for correct email links");
+
}
+
+
console.log("[Startup] Environment variable validation passed");
+
}
+
+
validateEnvVars();
+
// Environment variables
const WHISPER_SERVICE_URL =
process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
···
transcriptionEvents,
);
+
// Clean up expired sessions every 15 minutes
+
const sessionCleanupInterval = setInterval(
+
cleanupExpiredSessions,
+
15 * 60 * 1000,
+
);
// Helper function to sync user subscriptions from Polar
async function syncUserSubscriptionsFromPolar(
userId: number,
email: string,
): Promise<void> {
+
// Skip Polar sync in test mode
+
if (
+
process.env.NODE_ENV === "test" ||
+
process.env.SKIP_POLAR_SYNC === "true"
+
) {
+
return;
+
}
+
try {
const { polar } = await import("./lib/polar");
+
// Search for customer by email (validated at startup)
const customers = await polar.customers.list({
+
organizationId: process.env.POLAR_ORGANIZATION_ID as string,
query: email,
});
···
customerId: customer.id,
});
+
if (
+
!subscriptions.result.items ||
+
subscriptions.result.items.length === 0
+
) {
console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
return;
}
+
// Filter to only active/trialing/past_due subscriptions (not canceled/expired)
+
const currentSubscriptions = subscriptions.result.items.filter(
+
(sub) =>
+
sub.status === "active" ||
+
sub.status === "trialing" ||
+
sub.status === "past_due",
+
);
+
+
if (currentSubscriptions.length === 0) {
+
console.log(
+
`[Sync] No current subscriptions found for customer ${customer.id}`,
+
);
+
return;
+
}
+
+
// Update each current subscription in the database
+
for (const subscription of currentSubscriptions) {
db.run(
`INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
···
}
console.log(
+
`[Sync] Linked ${currentSubscriptions.length} current subscription(s) to user ${userId} (${email})`,
);
} catch (error) {
console.error(
···
// Don't throw - registration should succeed even if sync fails
}
}
// Sync with Whisper DB on startup
try {
···
}
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
+
const syncInterval = setInterval(
async () => {
try {
await whisperService.syncWithWhisper();
···
5 * 60 * 1000,
);
+
// Clean up stale files hourly
+
const fileCleanupInterval = setInterval(
+
() => whisperService.cleanupStaleFiles(),
+
60 * 60 * 1000, // 1 hour
+
);
const server = Bun.serve({
+
port:
+
process.env.NODE_ENV === "test"
+
? 3001
+
: process.env.PORT
+
? Number.parseInt(process.env.PORT, 10)
+
: 3000,
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
···
{ status: 400 },
);
}
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
const user = await createUser(email, password, name);
+
// Send verification email - MUST succeed for registration to complete
const { code, token, sentAt } = createEmailVerificationToken(user.id);
+
try {
await sendEmail({
to: user.email,
···
} catch (err) {
console.error("[Email] Failed to send verification email:", err);
// Rollback user creation - direct DB delete since user was just created
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
+
user.id,
+
]);
db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
db.run("DELETE FROM users WHERE id = ?", [user.id]);
return Response.json(
+
{
+
error:
+
"Failed to send verification email. Please try again later.",
+
},
{ status: 500 },
);
}
+
// Attempt to sync existing Polar subscriptions (after email succeeds)
syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
// Silent fail - don't block registration
});
+
// Clear rate limits on successful registration
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("register", email, ipAddress);
+
// Return success but indicate email verification is needed
// Don't create session yet - they need to verify first
return Response.json(
+
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: sentAt,
},
+
{ status: 201 },
);
} catch (err: unknown) {
const error = err as { message?: string };
if (error.message?.includes("UNIQUE constraint failed")) {
return Response.json(
{ error: "Email already registered" },
+
{ status: 409 },
);
}
console.error("[Auth] Registration error:", err);
···
});
if (rateLimitError) return rateLimitError;
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
{ status: 401 },
);
}
+
// Clear rate limits on successful authentication
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("login", email, ipAddress);
+
// Check if email is verified
if (!isEmailVerified(user.id)) {
let codeSentAt = getVerificationCodeSentAt(user.id);
+
// If no verification code exists, auto-send one
if (!codeSentAt) {
+
const { code, token, sentAt } = createEmailVerificationToken(
+
user.id,
+
);
codeSentAt = sentAt;
+
try {
await sendEmail({
to: user.email,
···
}),
});
} catch (err) {
+
console.error(
+
"[Email] Failed to send verification email on login:",
+
err,
+
);
// Don't fail login - just return null timestamp so client can try resend
codeSentAt = null;
}
}
+
return Response.json(
+
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: codeSentAt,
···
{ status: 200 },
);
}
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
···
return new Response(null, {
status: 302,
headers: {
+
Location: "/classes",
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
},
});
···
// Get user by email
const user = getUserByEmail(email);
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
}
// Check if already verified
···
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
+
{
+
success: true,
message: "Email verified successfully",
email_verified: true,
user: { id: user.id, email: user.email },
···
POST: async (req) => {
try {
const user = requireAuth(req);
+
// Rate limiting
const rateLimitError = enforceRateLimit(req, "resend-verification", {
account: { max: 3, windowSeconds: 60 * 60, email: user.email },
···
}),
});
+
return Response.json({
+
success: true,
+
message: "Verification email sent",
+
});
} catch (error) {
return handleError(error);
}
···
}
// Rate limiting by email
+
const rateLimitError = enforceRateLimit(
+
req,
+
"resend-verification-code",
+
{
+
account: { max: 3, windowSeconds: 5 * 60, email },
+
},
+
);
if (rateLimitError) return rateLimitError;
// Get user by email
const user = getUserByEmail(email);
if (!user) {
// Don't reveal if user exists
+
return Response.json({
+
success: true,
+
message:
+
"If an account exists with that email, a verification code has been sent",
+
});
}
// Check if already verified
···
}),
});
+
return Response.json({
+
success: true,
message: "Verification code sent",
verification_code_sent_at: sentAt,
});
···
// Always return success to prevent email enumeration
const user = getUserByEmail(email);
if (user) {
+
const origin = process.env.ORIGIN || "http://localhost:3000";
const resetToken = createPasswordResetToken(user.id);
const resetLink = `${origin}/reset-password?token=${resetToken}`;
···
}
return Response.json({
+
success: true,
message:
"If an account exists with that email, a password reset link has been sent",
});
···
},
},
"/api/auth/reset-password": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
+
+
if (!token) {
+
return Response.json({ error: "Token required" }, { status: 400 });
+
}
+
+
const userId = verifyPasswordResetToken(token);
+
if (!userId) {
+
return Response.json(
+
{ error: "Invalid or expired reset token" },
+
{ status: 400 },
+
);
+
}
+
+
// Get user's email for client-side password hashing
+
const user = db
+
.query<{ email: string }, [number]>(
+
"SELECT email FROM users WHERE id = ?",
+
)
+
.get(userId);
+
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
+
}
+
+
return Response.json({ email: user.email });
+
} catch (error) {
+
console.error("[Email] Get reset token info error:", error);
+
return Response.json(
+
{ error: "Failed to verify token" },
+
{ status: 500 },
+
);
+
}
+
},
POST: async (req) => {
try {
const body = await req.json();
···
}
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
await updateUserPassword(userId, password);
consumePasswordResetToken(token);
+
return Response.json({
+
success: true,
+
message: "Password reset successfully",
+
});
} catch (error) {
console.error("[Email] Reset password error:", error);
return Response.json(
···
},
"/api/auth/me": {
GET: (req) => {
+
try {
+
const user = requireAuth(req);
+
// Check subscription status
+
const subscription = db
+
.query<{ status: string }, [number]>(
+
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
+
)
+
.get(user.id);
+
// Get notification preferences
+
const prefs = db
+
.query<{ email_notifications_enabled: number }, [number]>(
+
"SELECT email_notifications_enabled FROM users WHERE id = ?",
+
)
+
.get(user.id);
+
return Response.json({
+
email: user.email,
+
name: user.name,
+
avatar: user.avatar,
+
created_at: user.created_at,
+
role: user.role,
+
has_subscription: !!subscription,
+
email_verified: isEmailVerified(user.id),
+
email_notifications_enabled:
+
prefs?.email_notifications_enabled === 1,
+
});
+
} catch (err) {
+
return handleError(err);
+
}
},
},
"/api/passkeys/register/options": {
POST: async (req) => {
try {
const user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(
+
req,
+
"passkey-register-options",
+
{
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
},
+
);
+
if (rateLimitError) return rateLimitError;
+
const options = await createRegistrationOptions(user);
return Response.json(options);
} catch (err) {
···
POST: async (req) => {
try {
const _user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(
+
req,
+
"passkey-register-verify",
+
{
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
},
+
);
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { response: credentialResponse, challenge, name } = body;
···
"/api/passkeys/authenticate/options": {
POST: async (req) => {
try {
+
const rateLimitError = enforceRateLimit(req, "passkey-auth-options", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { email } = body;
···
"/api/passkeys/authenticate/verify": {
POST: async (req) => {
try {
+
const rateLimitError = enforceRateLimit(req, "passkey-auth-verify", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { response: credentialResponse, challenge } = body;
···
PUT: async (req) => {
try {
const user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(req, "passkey-update", {
+
ip: { max: 10, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { name } = body;
const passkeyId = req.params.id;
···
}
updatePasskeyName(passkeyId, user.id, name);
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
}
···
DELETE: async (req) => {
try {
const user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(req, "passkey-delete", {
+
ip: { max: 10, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const passkeyId = req.params.id;
deletePasskey(passkeyId, user.id);
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
}
···
},
"/api/sessions": {
GET: (req) => {
+
try {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
const sessions = getUserSessionsForUser(user.id);
+
return Response.json({
+
sessions: sessions.map((s) => ({
+
id: s.id,
+
ip_address: s.ip_address,
+
user_agent: s.user_agent,
+
created_at: s.created_at,
+
expires_at: s.expires_at,
+
is_current: s.id === sessionId,
+
})),
+
});
+
} catch (err) {
+
return handleError(err);
}
},
DELETE: async (req) => {
+
try {
+
const currentSessionId = getSessionFromRequest(req);
+
if (!currentSessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
+
}
+
const user = getUserBySession(currentSessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const rateLimitError = enforceRateLimit(req, "delete-session", {
+
ip: { max: 20, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
const body = await req.json();
+
const targetSessionId = body.sessionId;
+
if (!targetSessionId) {
+
return Response.json(
+
{ error: "Session ID required" },
+
{ status: 400 },
+
);
+
}
+
// Prevent deleting current session
+
if (targetSessionId === currentSessionId) {
+
return Response.json(
+
{ error: "Cannot kill current session. Use logout instead." },
+
{ status: 400 },
+
);
+
}
+
// Verify the session belongs to the user
+
const targetSession = getSession(targetSessionId);
+
if (!targetSession || targetSession.user_id !== user.id) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
+
}
+
deleteSession(targetSessionId);
+
return new Response(null, { status: 204 });
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/user": {
DELETE: async (req) => {
+
try {
+
const user = requireAuth(req);
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "delete-user", {
+
ip: { max: 3, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
await deleteUser(user.id);
+
return new Response(null, {
+
status: 204,
headers: {
"Set-Cookie":
"session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
},
+
});
+
} catch (err) {
+
return handleError(err);
+
}
},
},
"/api/user/email": {
PUT: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-email", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
+
const { email } = body;
+
if (!email) {
+
return Response.json({ error: "Email required" }, { status: 400 });
+
}
+
// Check if email is already in use
+
const existingUser = getUserByEmail(email);
+
if (existingUser) {
return Response.json(
{ error: "Email already in use" },
+
{ status: 409 },
+
);
+
}
+
+
try {
+
// Create email change token
+
const token = createEmailChangeToken(user.id, email);
+
+
// Send verification email to the CURRENT address
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
+
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email change",
+
html: emailChangeTemplate({
+
name: user.name,
+
currentEmail: user.email,
+
newEmail: email,
+
verifyLink: verifyUrl,
+
}),
+
});
+
+
return Response.json({
+
success: true,
+
message: `Verification email sent to ${user.email}`,
+
pendingEmail: email,
+
});
+
} catch (error) {
+
console.error(
+
"[Email] Failed to send email change verification:",
+
error,
+
);
+
return Response.json(
+
{ error: "Failed to send verification email" },
+
{ status: 500 },
);
}
+
} catch (err) {
+
return handleError(err);
}
},
},
+
"/api/user/email/verify": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
+
if (!token) {
+
return Response.redirect(
+
"/settings?tab=account&error=invalid-token",
+
302,
+
);
+
}
+
const result = verifyEmailChangeToken(token);
+
+
if (!result) {
+
return Response.redirect(
+
"/settings?tab=account&error=expired-token",
+
302,
+
);
+
}
+
+
// Update the user's email
+
updateUserEmail(result.userId, result.newEmail);
+
+
// Consume the token
+
consumeEmailChangeToken(token);
+
+
// Redirect to settings with success message
+
return Response.redirect(
+
"/settings?tab=account&success=email-changed",
+
302,
+
);
+
} catch (error) {
+
console.error("[Email] Email change verification error:", error);
+
return Response.redirect(
+
"/settings?tab=account&error=verification-failed",
+
302,
);
}
+
},
+
},
+
"/api/user/password": {
+
PUT: async (req) => {
try {
+
const user = requireAuth(req);
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-password", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
const body = await req.json();
+
const { password } = body;
+
if (!password) {
+
return Response.json(
+
{ error: "Password required" },
+
{ status: 400 },
+
);
+
}
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
+
return Response.json(
+
{ error: passwordValidation.error },
+
{ status: 400 },
+
);
+
}
+
try {
+
await updateUserPassword(user.id, password);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update password" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/user/name": {
PUT: async (req) => {
try {
+
const user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(req, "update-name", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
const body = await req.json();
+
const { name } = body;
+
if (!name) {
+
return Response.json({ error: "Name required" }, { status: 400 });
+
}
+
try {
+
updateUserName(user.id, name);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update name" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/user/avatar": {
PUT: async (req) => {
try {
+
const user = requireAuth(req);
+
+
const rateLimitError = enforceRateLimit(req, "update-avatar", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
const body = await req.json();
+
const { avatar } = body;
+
if (!avatar) {
+
return Response.json({ error: "Avatar required" }, { status: 400 });
+
}
+
try {
+
updateUserAvatar(user.id, avatar);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update avatar" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/user/notifications": {
PUT: async (req) => {
try {
+
const user = requireAuth(req);
+
const rateLimitError = enforceRateLimit(req, "update-notifications", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
+
const { email_notifications_enabled } = body;
+
if (typeof email_notifications_enabled !== "boolean") {
return Response.json(
+
{ error: "email_notifications_enabled must be a boolean" },
+
{ status: 400 },
);
}
+
try {
+
db.run(
+
"UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
+
[email_notifications_enabled ? 1 : 0, user.id],
+
);
+
return Response.json({ success: true });
+
} catch {
return Response.json(
+
{ error: "Failed to update notification settings" },
{ status: 500 },
);
}
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/billing/checkout": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
const { polar } = await import("./lib/polar");
+
+
// Validated at startup
+
const productId = process.env.POLAR_PRODUCT_ID as string;
+
const successUrl =
+
process.env.POLAR_SUCCESS_URL || "http://localhost:3000";
const checkout = await polar.checkouts.create({
products: [productId],
···
});
return Response.json({ url: checkout.url });
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/billing/subscription": {
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
// Get subscription from database
const subscription = db
.query<
···
}
return Response.json({ subscription });
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/billing/portal": {
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
const { polar } = await import("./lib/polar");
// Get subscription to find customer ID
···
});
return Response.json({ url: session.customerPortalUrl });
+
} catch (err) {
+
return handleError(err);
}
},
},
"/api/webhooks/polar": {
POST: async (req) => {
+
const { validateEvent } = await import("@polar-sh/sdk/webhooks");
+
// Get raw body as string
+
const rawBody = await req.text();
+
const headers = Object.fromEntries(req.headers.entries());
+
// Validate webhook signature (validated at startup)
+
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET as string;
+
let event: ReturnType<typeof validateEvent>;
+
try {
+
event = validateEvent(rawBody, headers, webhookSecret);
+
} catch (error) {
+
// Validation failed - log but return generic response
+
console.error("[Webhook] Signature validation failed:", error);
+
return Response.json({ error: "Invalid webhook" }, { status: 400 });
+
}
+
console.log(`[Webhook] Received event: ${event.type}`);
+
// Handle different event types
+
try {
switch (event.type) {
case "subscription.updated": {
const { id, status, customerId, metadata } = event.data;
···
return Response.json({ received: true });
} catch (error) {
+
// Processing failed - log with detail but return generic response
+
console.error("[Webhook] Event processing failed:", error);
+
return Response.json({ error: "Invalid webhook" }, { status: 400 });
}
},
},
···
const transcriptionId = req.params.id;
// Verify ownership
const transcription = db
+
.query<
+
{
+
id: string;
+
user_id: number;
+
class_id: string | null;
+
status: string;
+
},
+
[string]
+
>(
"SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
)
.get(transcriptionId);
+
if (!transcription) {
return Response.json(
{ error: "Transcription not found" },
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
}
// Event-driven SSE stream with reconnection support
const stream = new ReadableStream({
async start(controller) {
+
// Track this stream for graceful shutdown
+
activeSSEStreams.add(controller);
+
const encoder = new TextEncoder();
let isClosed = false;
let lastEventId = Math.floor(Date.now() / 1000);
···
current?.status === "failed"
) {
isClosed = true;
+
activeSSEStreams.delete(controller);
controller.close();
return;
}
···
isClosed = true;
clearInterval(heartbeatInterval);
transcriptionEvents.off(transcriptionId, updateHandler);
+
activeSSEStreams.delete(controller);
controller.close();
}
};
···
isClosed = true;
clearInterval(heartbeatInterval);
transcriptionEvents.off(transcriptionId, updateHandler);
+
activeSSEStreams.delete(controller);
};
},
});
···
}
},
},
+
"/api/health": {
GET: async () => {
+
const health = {
+
status: "healthy",
+
timestamp: new Date().toISOString(),
+
services: {
+
database: false,
+
whisper: false,
+
storage: false,
+
},
+
details: {} as Record<string, unknown>,
+
};
+
+
// Check database
+
try {
+
db.query("SELECT 1").get();
+
health.services.database = true;
+
} catch (error) {
+
health.status = "unhealthy";
+
health.details.databaseError =
+
error instanceof Error ? error.message : String(error);
+
}
+
+
// Check Whisper service
+
try {
+
const whisperHealthy = await whisperService.checkHealth();
+
health.services.whisper = whisperHealthy;
+
if (!whisperHealthy) {
+
health.status = "degraded";
+
health.details.whisperNote = "Whisper service unavailable";
+
}
+
} catch (error) {
+
health.status = "degraded";
+
health.details.whisperError =
+
error instanceof Error ? error.message : String(error);
+
}
+
+
// Check storage (uploads and transcripts directories)
+
try {
+
const fs = await import("node:fs/promises");
+
const uploadsExists = await fs
+
.access("./uploads")
+
.then(() => true)
+
.catch(() => false);
+
const transcriptsExists = await fs
+
.access("./transcripts")
+
.then(() => true)
+
.catch(() => false);
+
health.services.storage = uploadsExists && transcriptsExists;
+
if (!health.services.storage) {
+
health.status = "unhealthy";
+
health.details.storageNote = `Missing directories: ${[
+
!uploadsExists && "uploads",
+
!transcriptsExists && "transcripts",
+
]
+
.filter(Boolean)
+
.join(", ")}`;
+
}
+
} catch (error) {
+
health.status = "unhealthy";
+
health.details.storageError =
+
error instanceof Error ? error.message : String(error);
+
}
+
+
const statusCode = health.status === "healthy" ? 200 : 503;
+
return Response.json(health, { status: statusCode });
},
},
"/api/transcriptions/:id": {
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
}
if (transcription.status !== "completed") {
return Response.json(
{ error: "Transcription not completed yet" },
+
{ status: 409 },
);
}
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
}
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
}
···
}
},
},
+
"/api/transcriptions/detect-meeting-time": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
const formData = await req.formData();
+
const file = formData.get("audio") as File;
+
const classId = formData.get("class_id") as string | null;
+
const fileTimestampStr = formData.get("file_timestamp") as
+
| string
+
| null;
+
+
if (!file) throw ValidationErrors.missingField("audio");
+
if (!classId) throw ValidationErrors.missingField("class_id");
+
+
// Verify user is enrolled in the class
+
const enrolled = isUserEnrolledInClass(user.id, classId);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
let creationDate: Date | null = null;
+
+
// Use client-provided timestamp (from File.lastModified)
+
if (fileTimestampStr) {
+
const timestamp = Number.parseInt(fileTimestampStr, 10);
+
if (!Number.isNaN(timestamp)) {
+
creationDate = new Date(timestamp);
+
console.log(
+
`[Upload] Using file timestamp: ${creationDate.toISOString()}`,
+
);
+
}
+
}
+
+
if (!creationDate) {
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
message: "Could not extract creation date from file",
+
});
+
}
+
+
// Get meeting times for this class
+
const meetingTimes = getMeetingTimesForClass(classId);
+
+
if (meetingTimes.length === 0) {
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
message: "No meeting times configured for this class",
+
});
+
}
+
+
// Find matching meeting time based on day of week
+
const matchedId = findMatchingMeetingTime(
+
creationDate,
+
meetingTimes,
+
);
+
+
if (matchedId) {
+
const dayName = getDayName(creationDate);
+
return Response.json({
+
detected: true,
+
meeting_time_id: matchedId,
+
day: dayName,
+
date: creationDate.toISOString(),
+
});
+
}
+
+
const dayName = getDayName(creationDate);
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
day: dayName,
+
date: creationDate.toISOString(),
+
message: `No meeting time matches ${dayName}`,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/transcriptions/:id/meeting-time": {
+
PATCH: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const transcriptionId = req.params.id;
+
+
const body = await req.json();
+
const meetingTimeId = body.meeting_time_id;
+
const sectionId = body.section_id;
+
+
if (!meetingTimeId) {
+
return Response.json(
+
{ error: "meeting_time_id required" },
+
{ status: 400 },
+
);
+
}
+
+
// Verify transcription ownership
+
const transcription = db
+
.query<
+
{ id: string; user_id: number; class_id: string | null },
+
[string]
+
>("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?")
+
.get(transcriptionId);
+
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
if (transcription.user_id !== user.id && user.role !== "admin") {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
+
}
+
+
// Verify meeting time belongs to the class
+
if (transcription.class_id) {
+
const meetingTime = db
+
.query<{ id: string }, [string, string]>(
+
"SELECT id FROM meeting_times WHERE id = ? AND class_id = ?",
+
)
+
.get(meetingTimeId, transcription.class_id);
+
+
if (!meetingTime) {
+
return Response.json(
+
{
+
error:
+
"Meeting time does not belong to the class for this transcription",
+
},
+
{ status: 400 },
+
);
+
}
+
}
+
+
// Update meeting time and optionally section_id
+
if (sectionId !== undefined) {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ?, section_id = ? WHERE id = ?",
+
[meetingTimeId, sectionId, transcriptionId],
+
);
+
} else {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
+
[meetingTimeId, transcriptionId],
+
);
+
}
+
+
return Response.json({
+
success: true,
+
message: "Meeting time updated successfully",
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:classId/meetings/:meetingTimeId/recordings": {
GET: async (req) => {
try {
+
const user = requireAuth(req);
+
const classId = req.params.classId;
+
const meetingTimeId = req.params.meetingTimeId;
+
+
// Verify user is enrolled in the class
+
const enrolled = isUserEnrolledInClass(user.id, classId);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
// Get section filter from query params or use user's section
+
const url = new URL(req.url);
+
const sectionParam = url.searchParams.get("section_id");
+
const sectionFilter =
+
sectionParam !== null
+
? sectionParam || null // empty string becomes null
+
: user.role === "admin"
+
? null
+
: getUserSection(user.id, classId);
+
const recordings = getPendingRecordings(
+
classId,
+
meetingTimeId,
+
sectionFilter,
+
);
+
const totalUsers = getEnrolledUserCount(classId);
+
const userVote = getUserVoteForMeeting(
+
user.id,
+
classId,
+
meetingTimeId,
+
);
+
+
// Check if any recording should be auto-submitted
+
const winningId = checkAutoSubmit(
+
classId,
+
meetingTimeId,
+
sectionFilter,
+
);
+
+
return Response.json({
+
recordings,
+
total_users: totalUsers,
+
user_vote: userVote,
+
vote_threshold: Math.ceil(totalUsers * 0.4),
+
winning_recording_id: winningId,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/recordings/:id/vote": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const recordingId = req.params.id;
+
+
// Verify user is enrolled in the recording's class
+
const recording = db
.query<
+
{ class_id: string; meeting_time_id: string; status: string },
+
[string]
>(
+
"SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
)
+
.get(recordingId);
+
+
if (!recording) {
+
return Response.json(
+
{ error: "Recording not found" },
+
{ status: 404 },
+
);
+
}
+
+
if (recording.status !== "pending") {
+
return Response.json(
+
{ error: "Can only vote on pending recordings" },
+
{ status: 400 },
+
);
+
}
+
+
const enrolled = isUserEnrolledInClass(user.id, recording.class_id);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
// Remove existing vote for this meeting time
+
const existingVote = getUserVoteForMeeting(
+
user.id,
+
recording.class_id,
+
recording.meeting_time_id,
+
);
+
if (existingVote) {
+
removeVote(existingVote, user.id);
+
}
+
+
// Add new vote
+
const success = voteForRecording(recordingId, user.id);
+
+
// Get user's section for auto-submit check
+
const userSection =
+
user.role === "admin"
+
? null
+
: getUserSection(user.id, recording.class_id);
+
+
// Check if auto-submit threshold reached
+
const winningId = checkAutoSubmit(
+
recording.class_id,
+
recording.meeting_time_id,
+
userSection,
+
);
+
if (winningId) {
+
markAsAutoSubmitted(winningId);
+
// Start transcription
+
const winningRecording = db
+
.query<{ filename: string }, [string]>(
+
"SELECT filename FROM transcriptions WHERE id = ?",
+
)
+
.get(winningId);
+
if (winningRecording) {
+
whisperService.startTranscription(
+
winningId,
+
winningRecording.filename,
+
);
+
}
+
}
+
+
return Response.json({
+
success,
+
winning_recording_id: winningId,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/recordings/:id": {
+
DELETE: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const recordingId = req.params.id;
+
+
const success = deletePendingRecording(
+
recordingId,
+
user.id,
+
user.role === "admin",
+
);
+
+
if (!success) {
+
return Response.json(
+
{ error: "Cannot delete this recording" },
+
{ status: 403 },
+
);
+
}
+
+
return new Response(null, { status: 204 });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/transcriptions": {
+
GET: async (req) => {
+
try {
+
const user = requireSubscription(req);
+
const url = new URL(req.url);
+
+
// Parse pagination params
+
const limit = Math.min(
+
Number.parseInt(url.searchParams.get("limit") || "50", 10),
+
100,
+
);
+
const cursorParam = url.searchParams.get("cursor");
+
+
let transcriptions: Array<{
+
id: string;
+
filename: string;
+
original_filename: string;
+
class_id: string | null;
+
status: string;
+
progress: number;
+
created_at: number;
+
}>;
+
+
if (cursorParam) {
+
// Decode cursor
+
const { decodeCursor } = await import("./lib/cursor");
+
const parts = decodeCursor(cursorParam);
+
+
if (parts.length !== 2) {
+
return Response.json(
+
{ error: "Invalid cursor format" },
+
{ status: 400 },
+
);
+
}
+
+
const cursorTime = Number.parseInt(parts[0] || "", 10);
+
const id = parts[1] || "";
+
+
if (Number.isNaN(cursorTime) || !id) {
+
return Response.json(
+
{ error: "Invalid cursor format" },
+
{ status: 400 },
+
);
+
}
+
+
transcriptions = db
+
.query<
+
{
+
id: string;
+
filename: string;
+
original_filename: string;
+
class_id: string | null;
+
status: string;
+
progress: number;
+
created_at: number;
+
},
+
[number, number, string, number]
+
>(
+
`SELECT id, filename, original_filename, class_id, status, progress, created_at
+
FROM transcriptions
+
WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?))
+
ORDER BY created_at DESC, id DESC
+
LIMIT ?`,
+
)
+
.all(user.id, cursorTime, cursorTime, id, limit + 1);
+
} else {
+
transcriptions = db
+
.query<
+
{
+
id: string;
+
filename: string;
+
original_filename: string;
+
class_id: string | null;
+
status: string;
+
progress: number;
+
created_at: number;
+
},
+
[number, number]
+
>(
+
`SELECT id, filename, original_filename, class_id, status, progress, created_at
+
FROM transcriptions
+
WHERE user_id = ?
+
ORDER BY created_at DESC, id DESC
+
LIMIT ?`,
+
)
+
.all(user.id, limit + 1);
+
}
+
+
// Check if there are more results
+
const hasMore = transcriptions.length > limit;
+
if (hasMore) {
+
transcriptions.pop(); // Remove extra item
+
}
+
+
// Build next cursor
+
let nextCursor: string | null = null;
+
if (hasMore && transcriptions.length > 0) {
+
const { encodeCursor } = await import("./lib/cursor");
+
const last = transcriptions[transcriptions.length - 1];
+
if (last) {
+
nextCursor = encodeCursor([last.created_at.toString(), last.id]);
+
}
+
}
// Load transcripts from files for completed jobs
const jobs = await Promise.all(
···
}),
);
+
return Response.json({
+
jobs,
+
pagination: {
+
limit,
+
hasMore,
+
nextCursor,
+
},
+
});
} catch (error) {
return handleError(error);
}
···
try {
const user = requireSubscription(req);
+
const rateLimitError = enforceRateLimit(req, "upload-transcription", {
+
ip: { max: 20, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const formData = await req.formData();
const file = formData.get("audio") as File;
const classId = formData.get("class_id") as string | null;
+
const sectionId = formData.get("section_id") as string | null;
+
const recordingDateStr = formData.get("recording_date") as
| string
| null;
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
+
// Parse recording date (default to current time if not provided)
+
const recordingDate = recordingDateStr
+
? Number.parseInt(recordingDateStr, 10)
+
: Math.floor(Date.now() / 1000);
+
+
// Create database record (without meeting_time_id - will be set later via PATCH)
db.run(
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
transcriptionId,
user.id,
classId,
+
null, // meeting_time_id will be set via PATCH endpoint
+
sectionId,
filename,
file.name,
"pending",
+
recordingDate,
],
);
// Don't auto-start transcription - admin will select recordings
// whisperService.startTranscription(transcriptionId, filename);
+
return Response.json(
+
{
+
id: transcriptionId,
+
message: "Upload successful",
+
},
+
{ status: 201 },
+
);
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
requireAdmin(req);
+
const url = new URL(req.url);
+
+
const limit = Math.min(
+
Number.parseInt(url.searchParams.get("limit") || "50", 10),
+
100,
+
);
+
const cursor = url.searchParams.get("cursor") || undefined;
+
+
const result = getAllTranscriptions(limit, cursor);
+
return Response.json(result.data); // Return just the array for now, can add pagination UI later
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
requireAdmin(req);
+
const url = new URL(req.url);
+
+
const limit = Math.min(
+
Number.parseInt(url.searchParams.get("limit") || "50", 10),
+
100,
+
);
+
const cursor = url.searchParams.get("cursor") || undefined;
+
+
const result = getAllUsersWithStats(limit, cursor);
+
return Response.json(result.data); // Return just the array for now, can add pagination UI later
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const id = req.params.id;
deleteWaitlistEntry(id);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
await deleteUser(userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
.get(userId);
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
}
try {
···
}
},
},
+
"/api/admin/users/:id/password-reset": {
+
POST: async (req) => {
try {
requireAdmin(req);
const userId = Number.parseInt(req.params.id, 10);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
+
// Get user details
+
const user = db
+
.query<
+
{ id: number; email: string; name: string | null },
+
[number]
+
>("SELECT id, email, name FROM users WHERE id = ?")
+
.get(userId);
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
}
+
// Create password reset token
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const resetToken = createPasswordResetToken(user.id);
+
const resetLink = `${origin}/reset-password?token=${resetToken}`;
+
+
// Send password reset email
+
await sendEmail({
+
to: user.email,
+
subject: "Reset your password - Thistle",
+
html: passwordResetTemplate({
+
name: user.name,
+
resetLink,
+
}),
+
});
+
+
return Response.json({
+
success: true,
+
message: "Password reset email sent",
+
});
} catch (error) {
+
console.error("[Admin] Password reset error:", error);
return handleError(error);
}
},
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
const body = await req.json();
const { name } = body as { name: string };
+
const nameValidation = validateName(name);
+
if (!nameValidation.valid) {
return Response.json(
+
{ error: nameValidation.error },
{ status: 400 },
);
}
···
}
const body = await req.json();
+
const { email, skipVerification } = body as {
+
email: string;
+
skipVerification?: boolean;
+
};
+
const emailValidation = validateEmail(email);
+
if (!emailValidation.valid) {
return Response.json(
+
{ error: emailValidation.error },
{ status: 400 },
);
}
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
+
{ status: 409 },
);
}
+
if (skipVerification) {
+
// Admin override: change email immediately without verification
+
updateUserEmailAddress(userId, email);
+
return Response.json({
+
success: true,
+
message: "Email updated immediately (verification skipped)",
+
});
+
}
+
+
// Get user's current email
+
const user = db
+
.query<{ email: string; name: string | null }, [number]>(
+
"SELECT email, name FROM users WHERE id = ?",
+
)
+
.get(userId);
+
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
+
}
+
+
// Send verification email to user's current email
+
try {
+
const token = createEmailChangeToken(userId, email);
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
+
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email change",
+
html: emailChangeTemplate({
+
name: user.name,
+
currentEmail: user.email,
+
newEmail: email,
+
verifyLink: verifyUrl,
+
}),
+
});
+
+
return Response.json({
+
success: true,
+
message: `Verification email sent to ${user.email}`,
+
pendingEmail: email,
+
});
+
} catch (emailError) {
+
console.error(
+
"[Admin] Failed to send email change verification:",
+
emailError,
+
);
+
return Response.json(
+
{ error: "Failed to send verification email" },
+
{ status: 500 },
+
);
+
}
} catch (error) {
return handleError(error);
}
···
}
deleteAllUserSessions(userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
);
}
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
GET: async (req) => {
try {
const user = requireAuth(req);
+
const url = new URL(req.url);
+
+
const limit = Math.min(
+
Number.parseInt(url.searchParams.get("limit") || "50", 10),
+
100,
+
);
+
const cursor = url.searchParams.get("cursor") || undefined;
+
+
const result = getClassesForUser(
+
user.id,
+
user.role === "admin",
+
limit,
+
cursor,
+
);
+
// Group by semester/year for all users
const grouped: Record<
string,
Array<{
···
}>
> = {};
+
for (const cls of result.data) {
const key = `${cls.semester} ${cls.year}`;
if (!grouped[key]) {
grouped[key] = [];
···
});
}
+
return Response.json({
+
classes: grouped,
+
pagination: result.pagination,
+
});
} catch (error) {
return handleError(error);
}
···
meeting_times,
} = body;
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(course_code);
+
if (!courseCodeValidation.valid) {
+
return Response.json(
+
{ error: courseCodeValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const nameValidation = validateCourseName(name);
+
if (!nameValidation.valid) {
+
return Response.json(
+
{ error: nameValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const professorValidation = validateName(professor, "Professor name");
+
if (!professorValidation.valid) {
return Response.json(
+
{ error: professorValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const semesterValidation = validateSemester(semester);
+
if (!semesterValidation.valid) {
+
return Response.json(
+
{ error: semesterValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const yearValidation = validateYear(year);
+
if (!yearValidation.valid) {
+
return Response.json(
+
{ error: yearValidation.error },
{ status: 400 },
);
}
···
semester,
year,
meeting_times,
+
sections: body.sections,
});
+
return Response.json(newClass, { status: 201 });
} catch (error) {
return handleError(error);
}
···
.all(user.id)
.map((row) => row.class_id);
+
// Add is_enrolled flag and sections to each class
const classesWithEnrollment = classes.map((cls) => ({
...cls,
is_enrolled: enrolledClassIds.includes(cls.id),
+
sections: getClassSections(cls.id),
}));
return Response.json({ classes: classesWithEnrollment });
···
const user = requireAuth(req);
const body = await req.json();
const classId = body.class_id;
+
const sectionId = body.section_id || null;
+
const classIdValidation = validateClassId(classId);
+
if (!classIdValidation.valid) {
return Response.json(
+
{ error: classIdValidation.error },
{ status: 400 },
);
}
+
const result = joinClass(classId, user.id, sectionId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
meetingTimes,
} = body;
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(courseCode);
+
if (!courseCodeValidation.valid) {
return Response.json(
+
{ error: courseCodeValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const nameValidation = validateCourseName(courseName);
+
if (!nameValidation.valid) {
+
return Response.json(
+
{ error: nameValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const professorValidation = validateName(professor, "Professor name");
+
if (!professorValidation.valid) {
+
return Response.json(
+
{ error: professorValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const semesterValidation = validateSemester(semester);
+
if (!semesterValidation.valid) {
+
return Response.json(
+
{ error: semesterValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const yearValidation = validateYear(
+
typeof year === "string" ? Number.parseInt(year, 10) : year,
+
);
+
if (!yearValidation.valid) {
+
return Response.json(
+
{ error: yearValidation.error },
{ status: 400 },
);
}
···
meetingTimes || null,
);
+
return Response.json({ success: true, id }, { status: 201 });
} catch (error) {
return handleError(error);
}
···
}
const meetingTimes = getMeetingTimesForClass(classId);
+
const sections = getClassSections(classId);
const transcriptions = getTranscriptionsForClass(classId);
+
const userSection = getUserSection(user.id, classId);
return Response.json({
class: classInfo,
meetingTimes,
+
sections,
+
userSection,
transcriptions,
});
} catch (error) {
···
requireAdmin(req);
const classId = req.params.id;
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
deleteClass(classId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
);
}
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
toggleClassArchive(classId, archived);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Email required" }, { status: 400 });
}
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
const user = getUserByEmail(email);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
enrollUserInClass(user.id, classId);
+
return new Response(null, { status: 201 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
removeUserFromClass(userId, classId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Label required" }, { status: 400 });
}
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
const meetingTime = createMeetingTime(classId, label);
+
return Response.json(meetingTime, { status: 201 });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/sections": {
+
POST: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const body = await req.json();
+
const { section_number } = body;
+
+
if (!section_number) {
+
return Response.json({ error: "Section number required" }, { status: 400 });
+
}
+
+
const section = createClassSection(classId, section_number);
+
return Response.json(section);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:classId/sections/:sectionId": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const sectionId = req.params.sectionId;
+
+
// Check if any students are in this section
+
const studentsInSection = db
+
.query<{ count: number }, [string]>(
+
"SELECT COUNT(*) as count FROM class_members WHERE section_id = ?",
+
)
+
.get(sectionId);
+
+
if (studentsInSection && studentsInSection.count > 0) {
+
return Response.json(
+
{ error: "Cannot delete section with enrolled students" },
+
{ status: 400 },
+
);
+
}
+
+
db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Label required" }, { status: 400 });
}
+
// Verify meeting exists
+
const existingMeeting = getMeetingById(meetingId);
+
if (!existingMeeting) {
+
return Response.json(
+
{ error: "Meeting not found" },
+
{ status: 404 },
+
);
+
}
+
updateMeetingTime(meetingId, label);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const meetingId = req.params.id;
+
// Verify meeting exists
+
const existingMeeting = getMeetingById(meetingId);
+
if (!existingMeeting) {
+
return Response.json(
+
{ error: "Meeting not found" },
+
{ status: 404 },
+
);
+
}
+
deleteMeetingTime(meetingId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
try {
requireAdmin(req);
const transcriptId = req.params.id;
+
+
// Check if transcription exists and get its current status
+
const transcription = db
+
.query<{ filename: string; status: string }, [string]>(
+
"SELECT filename, status FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptId);
+
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
// Validate that status is appropriate for selection (e.g., 'uploading' or 'pending')
+
const validStatuses = ["uploading", "pending", "failed"];
+
if (!validStatuses.includes(transcription.status)) {
+
return Response.json(
+
{
+
error: `Cannot select transcription with status: ${transcription.status}`,
+
},
+
{ status: 400 },
+
);
+
}
// Update status to 'selected' and start transcription
db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
···
transcriptId,
]);
+
whisperService.startTranscription(
+
transcriptId,
+
transcription.filename,
+
);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
},
},
},
+
development: process.env.NODE_ENV === "dev",
+
fetch(req, server) {
+
const response = server.fetch(req);
+
+
// Add security headers to all responses
+
if (response instanceof Response) {
+
const headers = new Headers(response.headers);
+
headers.set("Permissions-Policy", "interest-cohort=()");
+
headers.set("X-Content-Type-Options", "nosniff");
+
headers.set("X-Frame-Options", "DENY");
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
+
+
// Set CSP that allows inline styles with unsafe-inline (needed for Lit components)
+
// and script-src 'self' for bundled scripts
+
headers.set(
+
"Content-Security-Policy",
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://hostedboringavatars.vercel.app; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none';",
+
);
+
+
return new Response(response.body, {
+
status: response.status,
+
statusText: response.statusText,
+
headers,
+
});
+
}
+
+
return response;
},
});
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
+
+
// Track active SSE streams for graceful shutdown
+
const activeSSEStreams = new Set<ReadableStreamDefaultController>();
+
+
// Graceful shutdown handler
+
let isShuttingDown = false;
+
+
async function shutdown(signal: string) {
+
if (isShuttingDown) return;
+
isShuttingDown = true;
+
+
console.log(`\n${signal} received, starting graceful shutdown...`);
+
+
// 1. Stop accepting new requests
+
console.log("[Shutdown] Closing server...");
+
server.stop();
+
+
// 2. Close all active SSE streams (safe to kill - sync will handle reconnection)
+
console.log(
+
`[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`,
+
);
+
for (const controller of activeSSEStreams) {
+
try {
+
controller.close();
+
} catch {
+
// Already closed
+
}
+
}
+
activeSSEStreams.clear();
+
+
// 3. Stop transcription service (closes streams to Murmur)
+
whisperService.stop();
+
+
// 4. Stop cleanup intervals
+
console.log("[Shutdown] Stopping cleanup intervals...");
+
clearInterval(sessionCleanupInterval);
+
clearInterval(syncInterval);
+
clearInterval(fileCleanupInterval);
+
+
// 5. Close database connections
+
console.log("[Shutdown] Closing database...");
+
db.close();
+
+
console.log("[Shutdown] Complete");
+
process.exit(0);
+
}
+
+
// Register shutdown handlers
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
+
process.on("SIGINT", () => shutdown("SIGINT"));
+136
src/lib/api-response-format.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
+
/**
+
* API Response Format Standards
+
*
+
* This test documents the standardized response formats across the API.
+
* All endpoints should follow these patterns for consistency.
+
*/
+
+
describe("API Response Format Standards", () => {
+
test("success responses should include success: true", () => {
+
// Success-only responses (no data returned)
+
const successOnly = { success: true };
+
expect(successOnly).toHaveProperty("success", true);
+
+
// Success with message
+
const successWithMessage = {
+
success: true,
+
message: "Operation completed successfully",
+
};
+
expect(successWithMessage).toHaveProperty("success", true);
+
expect(successWithMessage).toHaveProperty("message");
+
+
// Success with data
+
const successWithData = {
+
success: true,
+
data: { id: 1, name: "Test" },
+
};
+
expect(successWithData).toHaveProperty("success", true);
+
expect(successWithData).toHaveProperty("data");
+
});
+
+
test("error responses should use error field", () => {
+
const errorResponse = { error: "Something went wrong" };
+
expect(errorResponse).toHaveProperty("error");
+
expect(typeof errorResponse.error).toBe("string");
+
});
+
+
test("data responses can return data directly", () => {
+
// Direct data return (common pattern for GET endpoints)
+
const userData = {
+
user: { id: 1, email: "test@example.com" },
+
has_subscription: true,
+
};
+
expect(userData).toHaveProperty("user");
+
+
// List responses
+
const listData = {
+
jobs: [{ id: "1" }, { id: "2" }],
+
pagination: { limit: 50, hasMore: false, nextCursor: null },
+
};
+
expect(listData).toHaveProperty("jobs");
+
expect(listData).toHaveProperty("pagination");
+
});
+
+
test("message-only responses are converted to success+message", () => {
+
// OLD (deprecated): { message: "..." }
+
// NEW (standard): { success: true, message: "..." }
+
+
const newFormat = {
+
success: true,
+
message: "Verification email sent",
+
};
+
+
expect(newFormat).toHaveProperty("success", true);
+
expect(newFormat).toHaveProperty("message");
+
});
+
});
+
+
describe("API Response Patterns", () => {
+
test("authentication responses", () => {
+
// Login success
+
const login = {
+
user: { id: 1, email: "test@example.com" },
+
email_verification_required: false,
+
};
+
expect(login).toHaveProperty("user");
+
+
// Logout success
+
const logout = { success: true };
+
expect(logout.success).toBe(true);
+
+
// Email verified
+
const verified = {
+
success: true,
+
message: "Email verified successfully",
+
email_verified: true,
+
user: { id: 1, email: "test@example.com" },
+
};
+
expect(verified.success).toBe(true);
+
expect(verified).toHaveProperty("message");
+
});
+
+
test("CRUD operation responses", () => {
+
// Create (returns created object)
+
const created = {
+
id: "123",
+
name: "New Item",
+
created_at: Date.now(),
+
};
+
expect(created).toHaveProperty("id");
+
+
// Update (returns success)
+
const updated = { success: true };
+
expect(updated.success).toBe(true);
+
+
// Delete (returns success)
+
const deleted = { success: true };
+
expect(deleted.success).toBe(true);
+
+
// Get (returns data directly)
+
const fetched = {
+
id: "123",
+
name: "Item",
+
};
+
expect(fetched).toHaveProperty("id");
+
});
+
+
test("paginated list responses", () => {
+
const paginatedList = {
+
data: [{ id: "1" }, { id: "2" }],
+
pagination: {
+
limit: 50,
+
hasMore: true,
+
nextCursor: "MTczMjM5NjgwMHx0cmFucy0xMjM",
+
},
+
};
+
+
expect(paginatedList).toHaveProperty("data");
+
expect(Array.isArray(paginatedList.data)).toBe(true);
+
expect(paginatedList).toHaveProperty("pagination");
+
expect(paginatedList.pagination).toHaveProperty("limit");
+
expect(paginatedList.pagination).toHaveProperty("hasMore");
+
expect(paginatedList.pagination).toHaveProperty("nextCursor");
+
});
+
});
+55
src/lib/audio-metadata.integration.test.ts
···
···
+
import { afterAll, describe, expect, test } from "bun:test";
+
import { extractAudioCreationDate } from "./audio-metadata";
+
+
describe("extractAudioCreationDate (integration)", () => {
+
const testAudioPath = "./test-audio-sample.m4a";
+
+
// Clean up test file after tests
+
afterAll(async () => {
+
try {
+
await Bun.file(testAudioPath).exists().then(async (exists) => {
+
if (exists) {
+
await Bun.$`rm ${testAudioPath}`;
+
}
+
});
+
} catch {
+
// Ignore cleanup errors
+
}
+
});
+
+
test("extracts creation date from audio file with metadata", async () => {
+
// Create a test audio file with metadata using ffmpeg
+
// 1 second silent audio with creation_time metadata
+
const creationTime = "2024-01-15T14:30:00.000000Z";
+
+
// Create the file with metadata
+
await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -metadata creation_time=${creationTime} -y ${testAudioPath}`.quiet();
+
+
const date = await extractAudioCreationDate(testAudioPath);
+
+
expect(date).not.toBeNull();
+
expect(date).toBeInstanceOf(Date);
+
// JavaScript Date.toISOString() uses 3 decimal places, not 6 like the input
+
expect(date?.toISOString()).toBe("2024-01-15T14:30:00.000Z");
+
});
+
+
test("returns null for audio file without creation_time metadata", async () => {
+
// Create audio file without metadata
+
await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -y ${testAudioPath}`.quiet();
+
+
const date = await extractAudioCreationDate(testAudioPath);
+
+
// Should use file modification time as fallback
+
expect(date).not.toBeNull();
+
expect(date).toBeInstanceOf(Date);
+
// Should be very recent (within last minute)
+
const now = new Date();
+
const diff = now.getTime() - (date?.getTime() ?? 0);
+
expect(diff).toBeLessThan(60000); // Less than 1 minute
+
});
+
+
test("returns null for non-existent file", async () => {
+
const date = await extractAudioCreationDate("./non-existent-file.m4a");
+
expect(date).toBeNull();
+
});
+
});
+128
src/lib/audio-metadata.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import {
+
findMatchingMeetingTime,
+
getDayName,
+
getDayOfWeek,
+
meetingTimeLabelMatchesDay,
+
} from "./audio-metadata";
+
+
describe("getDayOfWeek", () => {
+
test("returns correct day number", () => {
+
// January 1, 2024 is a Monday (day 1)
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(getDayOfWeek(monday)).toBe(1);
+
+
// January 7, 2024 is a Sunday (day 0)
+
const sunday = new Date("2024-01-07T12:00:00Z");
+
expect(getDayOfWeek(sunday)).toBe(0);
+
+
// January 6, 2024 is a Saturday (day 6)
+
const saturday = new Date("2024-01-06T12:00:00Z");
+
expect(getDayOfWeek(saturday)).toBe(6);
+
});
+
});
+
+
describe("getDayName", () => {
+
test("returns correct day name", () => {
+
expect(getDayName(new Date("2024-01-01T12:00:00Z"))).toBe("Monday");
+
expect(getDayName(new Date("2024-01-02T12:00:00Z"))).toBe("Tuesday");
+
expect(getDayName(new Date("2024-01-03T12:00:00Z"))).toBe("Wednesday");
+
expect(getDayName(new Date("2024-01-04T12:00:00Z"))).toBe("Thursday");
+
expect(getDayName(new Date("2024-01-05T12:00:00Z"))).toBe("Friday");
+
expect(getDayName(new Date("2024-01-06T12:00:00Z"))).toBe("Saturday");
+
expect(getDayName(new Date("2024-01-07T12:00:00Z"))).toBe("Sunday");
+
});
+
});
+
+
describe("meetingTimeLabelMatchesDay", () => {
+
test("matches full day names", () => {
+
expect(meetingTimeLabelMatchesDay("Monday Lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Tuesday Lab", "Tuesday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Wednesday Discussion", "Wednesday")).toBe(
+
true,
+
);
+
});
+
+
test("matches 3-letter abbreviations", () => {
+
expect(meetingTimeLabelMatchesDay("Mon Lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Tue Lab", "Tuesday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Wed Discussion", "Wednesday")).toBe(
+
true,
+
);
+
expect(meetingTimeLabelMatchesDay("Thu Seminar", "Thursday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Fri Workshop", "Friday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Sat Review", "Saturday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Sun Study", "Sunday")).toBe(true);
+
});
+
+
test("is case insensitive", () => {
+
expect(meetingTimeLabelMatchesDay("MONDAY LECTURE", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("monday lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("MoNdAy LeCTuRe", "Monday")).toBe(true);
+
});
+
+
test("does not match wrong days", () => {
+
expect(meetingTimeLabelMatchesDay("Monday Lecture", "Tuesday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Wednesday Lab", "Thursday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Lecture Hall A", "Monday")).toBe(false);
+
});
+
+
test("handles labels without day names", () => {
+
expect(meetingTimeLabelMatchesDay("Lecture", "Monday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Lab Session", "Tuesday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Section A", "Wednesday")).toBe(false);
+
});
+
});
+
+
describe("findMatchingMeetingTime", () => {
+
const meetingTimes = [
+
{ id: "mt1", label: "Monday Lecture" },
+
{ id: "mt2", label: "Wednesday Discussion" },
+
{ id: "mt3", label: "Friday Lab" },
+
];
+
+
test("finds correct meeting time for full day name", () => {
+
const monday = new Date("2024-01-01T12:00:00Z"); // Monday
+
expect(findMatchingMeetingTime(monday, meetingTimes)).toBe("mt1");
+
+
const wednesday = new Date("2024-01-03T12:00:00Z"); // Wednesday
+
expect(findMatchingMeetingTime(wednesday, meetingTimes)).toBe("mt2");
+
+
const friday = new Date("2024-01-05T12:00:00Z"); // Friday
+
expect(findMatchingMeetingTime(friday, meetingTimes)).toBe("mt3");
+
});
+
+
test("finds correct meeting time for abbreviated day name", () => {
+
const abbrevMeetingTimes = [
+
{ id: "mt1", label: "Mon Lecture" },
+
{ id: "mt2", label: "Wed Discussion" },
+
{ id: "mt3", label: "Fri Lab" },
+
];
+
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, abbrevMeetingTimes)).toBe("mt1");
+
});
+
+
test("returns null when no match found", () => {
+
const tuesday = new Date("2024-01-02T12:00:00Z"); // Tuesday
+
expect(findMatchingMeetingTime(tuesday, meetingTimes)).toBe(null);
+
+
const saturday = new Date("2024-01-06T12:00:00Z"); // Saturday
+
expect(findMatchingMeetingTime(saturday, meetingTimes)).toBe(null);
+
});
+
+
test("returns null for empty meeting times", () => {
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, [])).toBe(null);
+
});
+
+
test("returns first match when multiple matches exist", () => {
+
const duplicateMeetingTimes = [
+
{ id: "mt1", label: "Monday Lecture" },
+
{ id: "mt2", label: "Monday Lab" },
+
];
+
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, duplicateMeetingTimes)).toBe("mt1");
+
});
+
});
+144
src/lib/audio-metadata.ts
···
···
+
import { $ } from "bun";
+
+
/**
+
* Extracts creation date from audio file metadata using ffprobe
+
* Falls back to file birth time (original creation) if no metadata found
+
* @param filePath Path to audio file
+
* @returns Date object or null if not found
+
*/
+
export async function extractAudioCreationDate(
+
filePath: string,
+
): Promise<Date | null> {
+
try {
+
// Use ffprobe to extract creation_time metadata
+
// -v quiet: suppress verbose output
+
// -print_format json: output as JSON
+
// -show_entries format_tags: show all tags to search for date fields
+
const result =
+
await $`ffprobe -v quiet -print_format json -show_entries format_tags ${filePath}`.text();
+
+
const metadata = JSON.parse(result);
+
const tags = metadata?.format?.tags || {};
+
+
// Try multiple metadata fields that might contain creation date
+
const dateFields = [
+
tags.creation_time, // Standard creation_time
+
tags.date, // Common date field
+
tags.DATE, // Uppercase variant
+
tags.year, // Year field
+
tags.YEAR, // Uppercase variant
+
tags["com.apple.quicktime.creationdate"], // Apple QuickTime
+
tags.TDRC, // ID3v2 recording time
+
tags.TDRL, // ID3v2 release time
+
];
+
+
for (const dateField of dateFields) {
+
if (dateField) {
+
const date = new Date(dateField);
+
if (!Number.isNaN(date.getTime())) {
+
console.log(
+
`[AudioMetadata] Extracted creation date from metadata: ${date.toISOString()} from ${filePath}`,
+
);
+
return date;
+
}
+
}
+
}
+
+
// Fallback: use file birth time (original creation time on filesystem)
+
// This preserves the original file creation date better than mtime
+
console.log(
+
`[AudioMetadata] No creation_time metadata found, using file birth time`,
+
);
+
const file = Bun.file(filePath);
+
const stat = await file.stat();
+
const date = new Date(stat.birthtime || stat.mtime);
+
console.log(
+
`[AudioMetadata] Using file birth time: ${date.toISOString()} from ${filePath}`,
+
);
+
return date;
+
} catch (error) {
+
console.error(
+
`[AudioMetadata] Failed to extract metadata from ${filePath}:`,
+
error instanceof Error ? error.message : "Unknown error",
+
);
+
return null;
+
}
+
}
+
+
/**
+
* Gets day of week from a date (0 = Sunday, 6 = Saturday)
+
*/
+
export function getDayOfWeek(date: Date): number {
+
return date.getDay();
+
}
+
+
/**
+
* Gets day name from a date
+
*/
+
export function getDayName(date: Date): string {
+
const days = [
+
"Sunday",
+
"Monday",
+
"Tuesday",
+
"Wednesday",
+
"Thursday",
+
"Friday",
+
"Saturday",
+
];
+
return days[date.getDay()] || "Unknown";
+
}
+
+
/**
+
* Checks if a meeting time label matches a specific day
+
* Labels like "Monday Lecture", "Tuesday Lab", "Wed Discussion" should match
+
*/
+
export function meetingTimeLabelMatchesDay(
+
label: string,
+
dayName: string,
+
): boolean {
+
const lowerLabel = label.toLowerCase();
+
const lowerDay = dayName.toLowerCase();
+
+
// Check for full day name
+
if (lowerLabel.includes(lowerDay)) {
+
return true;
+
}
+
+
// Check for 3-letter abbreviations
+
const abbrev = dayName.slice(0, 3).toLowerCase();
+
if (lowerLabel.includes(abbrev)) {
+
return true;
+
}
+
+
return false;
+
}
+
+
/**
+
* Finds the best matching meeting time for a given date
+
* @param date Date from audio metadata
+
* @param meetingTimes Available meeting times for the class
+
* @returns Meeting time ID or null if no match
+
*/
+
export function findMatchingMeetingTime(
+
date: Date,
+
meetingTimes: Array<{ id: string; label: string }>,
+
): string | null {
+
const dayName = getDayName(date);
+
+
// Find meeting time that matches the day
+
const match = meetingTimes.find((mt) =>
+
meetingTimeLabelMatchesDay(mt.label, dayName),
+
);
+
+
if (match) {
+
console.log(
+
`[AudioMetadata] Matched ${dayName} to meeting time: ${match.label}`,
+
);
+
return match.id;
+
}
+
+
console.log(
+
`[AudioMetadata] No meeting time found matching ${dayName} in available options: ${meetingTimes.map((mt) => mt.label).join(", ")}`,
+
);
+
return null;
+
}
+34
src/lib/auth.test.ts
···
};
expect(typeof result.count).toBe("number");
});
···
};
expect(typeof result.count).toBe("number");
});
+
+
test("enforces maximum session limit per user", () => {
+
const userId = 999;
+
+
// Clean up any existing sessions for this user
+
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
+
// Create 11 sessions (limit is 10)
+
const sessionIds: string[] = [];
+
for (let i = 0; i < 11; i++) {
+
const sessionId = createSession(userId, `192.168.1.${i}`, `Agent ${i}`);
+
sessionIds.push(sessionId);
+
}
+
+
// Count total sessions for user
+
const sessionCount = db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM sessions WHERE user_id = ?",
+
)
+
.get(userId);
+
+
expect(sessionCount?.count).toBe(10);
+
+
// First session should be deleted (oldest)
+
const firstSession = getSession(sessionIds[0]);
+
expect(firstSession).toBeNull();
+
+
// Last session should exist (newest)
+
const lastSession = getSession(sessionIds[10]);
+
expect(lastSession).not.toBeNull();
+
+
// Cleanup
+
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
});
+300 -81
src/lib/auth.ts
···
import db from "../db/schema";
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
export type UserRole = "user" | "admin";
···
): string {
const sessionId = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
db.run(
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
···
// Get user's subscription if they have one
const subscription = db
-
.query<{ id: string }, [number]>(
-
"SELECT id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
)
.get(userId);
-
// Cancel subscription if it exists (soft cancel - keeps access until period end)
-
if (subscription) {
try {
const { polar } = await import("./polar");
await polar.subscriptions.update({
···
);
// Continue with user deletion even if subscription cancellation fails
}
}
// Reassign class transcriptions to ghost user (id=0)
···
"UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL",
[userId],
);
-
db.run(
-
"DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL",
-
[userId],
-
);
// Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members)
db.run("DELETE FROM users WHERE id = ?", [userId]);
···
* Email verification functions
*/
-
export function createEmailVerificationToken(userId: number): { code: string; token: string; sentAt: number } {
// Generate a 6-digit code for user to enter
const code = Math.floor(100000 + Math.random() * 900000).toString();
const id = crypto.randomUUID();
···
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
[id, userId, code, expiresAt],
);
-
// Store the URL token as a separate entry
db.run(
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
···
const now = Math.floor(Date.now() / 1000);
const result = db
-
.query<
-
{ user_id: number; email: string },
-
[string, number]
-
>(
`SELECT evt.user_id, u.email
FROM email_verification_tokens evt
JOIN users u ON evt.user_id = u.id
···
return { userId: result.user_id, email: result.email };
}
-
export function verifyEmailCode(
-
userId: number,
-
code: string,
-
): boolean {
const now = Math.floor(Date.now() / 1000);
const result = db
-
.query<
-
{ user_id: number },
-
[number, string, number]
-
>(
`SELECT user_id
FROM email_verification_tokens
WHERE user_id = ? AND token = ? AND expires_at > ?`,
···
db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
}
export function isUserAdmin(userId: number): boolean {
const result = db
.query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
···
.all();
}
-
export function getAllTranscriptions(): Array<{
-
id: string;
-
user_id: number;
-
user_email: string;
-
user_name: string | null;
-
original_filename: string;
-
status: string;
-
created_at: number;
-
error_message: string | null;
-
}> {
-
return db
-
.query<
-
{
-
id: string;
-
user_id: number;
-
user_email: string;
-
user_name: string | null;
-
original_filename: string;
-
status: string;
-
created_at: number;
-
error_message: string | null;
-
},
-
[]
-
>(
-
`SELECT
-
t.id,
-
t.user_id,
-
u.email as user_email,
-
u.name as user_name,
-
t.original_filename,
-
t.status,
-
t.created_at,
-
t.error_message
-
FROM transcriptions t
-
LEFT JOIN users u ON t.user_id = u.id
-
ORDER BY t.created_at DESC`,
-
)
-
.all();
}
export function deleteTranscription(transcriptionId: string): void {
···
subscription_id: string | null;
}
-
export function getAllUsersWithStats(): UserWithStats[] {
-
return db
-
.query<UserWithStats, []>(
-
`SELECT
-
u.id,
-
u.email,
-
u.name,
-
u.avatar,
-
u.created_at,
-
u.role,
-
u.last_login,
-
COUNT(DISTINCT t.id) as transcription_count,
-
s.status as subscription_status,
-
s.id as subscription_id
-
FROM users u
-
LEFT JOIN transcriptions t ON u.id = t.user_id
-
LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
-
GROUP BY u.id
-
ORDER BY u.created_at DESC`,
-
)
-
.all();
}
···
import db from "../db/schema";
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
+
const MAX_SESSIONS_PER_USER = 10; // Maximum number of sessions per user
export type UserRole = "user" | "admin";
···
): string {
const sessionId = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
+
+
// Check current session count for user
+
const sessionCount = db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM sessions WHERE user_id = ?",
+
)
+
.get(userId);
+
+
// If at or over limit, delete oldest session(s)
+
if (sessionCount && sessionCount.count >= MAX_SESSIONS_PER_USER) {
+
const sessionsToDelete = sessionCount.count - MAX_SESSIONS_PER_USER + 1;
+
db.run(
+
`DELETE FROM sessions WHERE id IN (
+
SELECT id FROM sessions
+
WHERE user_id = ?
+
ORDER BY created_at ASC
+
LIMIT ?
+
)`,
+
[userId, sessionsToDelete],
+
);
+
}
db.run(
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
···
// Get user's subscription if they have one
const subscription = db
+
.query<
+
{ id: string; status: string; cancel_at_period_end: number },
+
[number]
+
>(
+
"SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
)
.get(userId);
+
// Cancel subscription if it exists and is not already canceled or scheduled to cancel
+
if (
+
subscription &&
+
subscription.status !== "canceled" &&
+
subscription.status !== "expired" &&
+
!subscription.cancel_at_period_end
+
) {
try {
const { polar } = await import("./polar");
await polar.subscriptions.update({
···
);
// Continue with user deletion even if subscription cancellation fails
}
+
} else if (subscription) {
+
console.log(
+
`[User Delete] Skipping cancellation for subscription ${subscription.id} (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})`,
+
);
}
// Reassign class transcriptions to ghost user (id=0)
···
"UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL",
[userId],
);
+
db.run("DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", [
+
userId,
+
]);
// Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members)
db.run("DELETE FROM users WHERE id = ?", [userId]);
···
* Email verification functions
*/
+
export function createEmailVerificationToken(userId: number): {
+
code: string;
+
token: string;
+
sentAt: number;
+
} {
// Generate a 6-digit code for user to enter
const code = Math.floor(100000 + Math.random() * 900000).toString();
const id = crypto.randomUUID();
···
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
[id, userId, code, expiresAt],
);
+
// Store the URL token as a separate entry
db.run(
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
···
const now = Math.floor(Date.now() / 1000);
const result = db
+
.query<{ user_id: number; email: string }, [string, number]>(
`SELECT evt.user_id, u.email
FROM email_verification_tokens evt
JOIN users u ON evt.user_id = u.id
···
return { userId: result.user_id, email: result.email };
}
+
export function verifyEmailCode(userId: number, code: string): boolean {
const now = Math.floor(Date.now() / 1000);
const result = db
+
.query<{ user_id: number }, [number, string, number]>(
`SELECT user_id
FROM email_verification_tokens
WHERE user_id = ? AND token = ? AND expires_at > ?`,
···
db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
}
+
/**
+
* Email change functions
+
*/
+
+
export function createEmailChangeToken(
+
userId: number,
+
newEmail: string,
+
): string {
+
const token = crypto.randomUUID();
+
const id = crypto.randomUUID();
+
const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
+
+
// Delete any existing email change tokens for this user
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [userId]);
+
+
db.run(
+
"INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)",
+
[id, userId, newEmail, token, expiresAt],
+
);
+
+
return token;
+
}
+
+
export function verifyEmailChangeToken(
+
token: string,
+
): { userId: number; newEmail: string } | null {
+
const now = Math.floor(Date.now() / 1000);
+
+
const result = db
+
.query<{ user_id: number; new_email: string }, [string, number]>(
+
"SELECT user_id, new_email FROM email_change_tokens WHERE token = ? AND expires_at > ?",
+
)
+
.get(token, now);
+
+
if (!result) return null;
+
+
return { userId: result.user_id, newEmail: result.new_email };
+
}
+
+
export function consumeEmailChangeToken(token: string): void {
+
db.run("DELETE FROM email_change_tokens WHERE token = ?", [token]);
+
}
+
export function isUserAdmin(userId: number): boolean {
const result = db
.query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
···
.all();
}
+
export function getAllTranscriptions(
+
limit = 50,
+
cursor?: string,
+
): {
+
data: Array<{
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
}>;
+
pagination: {
+
limit: number;
+
hasMore: boolean;
+
nextCursor: string | null;
+
};
+
} {
+
type TranscriptionRow = {
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
};
+
+
let transcriptions: TranscriptionRow[];
+
+
if (cursor) {
+
const { decodeCursor } = require("./cursor");
+
const parts = decodeCursor(cursor);
+
+
if (parts.length !== 2) {
+
throw new Error("Invalid cursor format");
+
}
+
+
const cursorTime = Number.parseInt(parts[0] || "", 10);
+
const id = parts[1] || "";
+
+
if (Number.isNaN(cursorTime) || !id) {
+
throw new Error("Invalid cursor format");
+
}
+
+
transcriptions = db
+
.query<TranscriptionRow, [number, number, string, number]>(
+
`SELECT
+
t.id,
+
t.user_id,
+
u.email as user_email,
+
u.name as user_name,
+
t.original_filename,
+
t.status,
+
t.created_at,
+
t.error_message
+
FROM transcriptions t
+
LEFT JOIN users u ON t.user_id = u.id
+
WHERE t.created_at < ? OR (t.created_at = ? AND t.id < ?)
+
ORDER BY t.created_at DESC, t.id DESC
+
LIMIT ?`,
+
)
+
.all(cursorTime, cursorTime, id, limit + 1);
+
} else {
+
transcriptions = db
+
.query<TranscriptionRow, [number]>(
+
`SELECT
+
t.id,
+
t.user_id,
+
u.email as user_email,
+
u.name as user_name,
+
t.original_filename,
+
t.status,
+
t.created_at,
+
t.error_message
+
FROM transcriptions t
+
LEFT JOIN users u ON t.user_id = u.id
+
ORDER BY t.created_at DESC, t.id DESC
+
LIMIT ?`,
+
)
+
.all(limit + 1);
+
}
+
+
const hasMore = transcriptions.length > limit;
+
if (hasMore) {
+
transcriptions.pop();
+
}
+
+
let nextCursor: string | null = null;
+
if (hasMore && transcriptions.length > 0) {
+
const { encodeCursor } = require("./cursor");
+
const last = transcriptions[transcriptions.length - 1];
+
if (last) {
+
nextCursor = encodeCursor([last.created_at.toString(), last.id]);
+
}
+
}
+
+
return {
+
data: transcriptions,
+
pagination: {
+
limit,
+
hasMore,
+
nextCursor,
+
},
+
};
}
export function deleteTranscription(transcriptionId: string): void {
···
subscription_id: string | null;
}
+
export function getAllUsersWithStats(
+
limit = 50,
+
cursor?: string,
+
): {
+
data: UserWithStats[];
+
pagination: {
+
limit: number;
+
hasMore: boolean;
+
nextCursor: string | null;
+
};
+
} {
+
let users: UserWithStats[];
+
+
if (cursor) {
+
const { decodeCursor } = require("./cursor");
+
const parts = decodeCursor(cursor);
+
+
if (parts.length !== 2) {
+
throw new Error("Invalid cursor format");
+
}
+
+
const cursorTime = Number.parseInt(parts[0] || "", 10);
+
const cursorId = Number.parseInt(parts[1] || "", 10);
+
+
if (Number.isNaN(cursorTime) || Number.isNaN(cursorId)) {
+
throw new Error("Invalid cursor format");
+
}
+
+
users = db
+
.query<UserWithStats, [number, number, number, number]>(
+
`SELECT
+
u.id,
+
u.email,
+
u.name,
+
u.avatar,
+
u.created_at,
+
u.role,
+
u.last_login,
+
COUNT(DISTINCT t.id) as transcription_count,
+
s.status as subscription_status,
+
s.id as subscription_id
+
FROM users u
+
LEFT JOIN transcriptions t ON u.id = t.user_id
+
LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
+
WHERE u.created_at < ? OR (u.created_at = ? AND u.id < ?)
+
GROUP BY u.id
+
ORDER BY u.created_at DESC, u.id DESC
+
LIMIT ?`,
+
)
+
.all(cursorTime, cursorTime, cursorId, limit + 1);
+
} else {
+
users = db
+
.query<UserWithStats, [number]>(
+
`SELECT
+
u.id,
+
u.email,
+
u.name,
+
u.avatar,
+
u.created_at,
+
u.role,
+
u.last_login,
+
COUNT(DISTINCT t.id) as transcription_count,
+
s.status as subscription_status,
+
s.id as subscription_id
+
FROM users u
+
LEFT JOIN transcriptions t ON u.id = t.user_id
+
LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
+
GROUP BY u.id
+
ORDER BY u.created_at DESC, u.id DESC
+
LIMIT ?`,
+
)
+
.all(limit + 1);
+
}
+
+
const hasMore = users.length > limit;
+
if (hasMore) {
+
users.pop();
+
}
+
+
let nextCursor: string | null = null;
+
if (hasMore && users.length > 0) {
+
const { encodeCursor } = require("./cursor");
+
const last = users[users.length - 1];
+
if (last) {
+
nextCursor = encodeCursor([
+
last.created_at.toString(),
+
last.id.toString(),
+
]);
+
}
+
}
+
+
return {
+
data: users,
+
pagination: {
+
limit,
+
hasMore,
+
nextCursor,
+
},
+
};
}
+7 -7
src/lib/classes.test.ts
···
enrollUserInClass(userId, cls1.id);
// Get classes for user (non-admin)
-
const classes = getClassesForUser(userId, false);
-
expect(classes.length).toBe(1);
-
expect(classes[0]?.id).toBe(cls1.id);
// Admin should see all classes (not just the 2 test classes, but all in DB)
-
const allClasses = getClassesForUser(userId, true);
-
expect(allClasses.length).toBeGreaterThanOrEqual(2);
-
expect(allClasses.some((c) => c.id === cls1.id)).toBe(true);
-
expect(allClasses.some((c) => c.id === cls2.id)).toBe(true);
// Cleanup enrollment
removeUserFromClass(userId, cls1.id);
···
enrollUserInClass(userId, cls1.id);
// Get classes for user (non-admin)
+
const classesResult = getClassesForUser(userId, false);
+
expect(classesResult.data.length).toBe(1);
+
expect(classesResult.data[0]?.id).toBe(cls1.id);
// Admin should see all classes (not just the 2 test classes, but all in DB)
+
const allClassesResult = getClassesForUser(userId, true);
+
expect(allClassesResult.data.length).toBeGreaterThanOrEqual(2);
+
expect(allClassesResult.data.some((c) => c.id === cls1.id)).toBe(true);
+
expect(allClassesResult.data.some((c) => c.id === cls2.id)).toBe(true);
// Cleanup enrollment
removeUserFromClass(userId, cls1.id);
+248 -22
src/lib/classes.ts
···
semester: string;
year: number;
archived: boolean;
created_at: number;
}
···
export interface ClassMember {
class_id: string;
user_id: number;
enrolled_at: number;
}
/**
* Get all classes for a user (either enrolled or admin sees all)
*/
-
export function getClassesForUser(userId: number, isAdmin: boolean): Class[] {
if (isAdmin) {
-
return db
-
.query<Class, []>(
-
"SELECT * FROM classes ORDER BY year DESC, semester DESC, course_code ASC",
-
)
-
.all();
}
-
return db
-
.query<Class, [number]>(
-
`SELECT c.* FROM classes c
-
INNER JOIN class_members cm ON c.id = cm.class_id
-
WHERE cm.user_id = ?
-
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC`,
-
)
-
.all(userId);
}
/**
···
semester: string;
year: number;
meeting_times?: string[];
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
}
}
return {
id,
course_code: data.course_code,
···
* Archive or unarchive a class
*/
export function toggleClassArchive(classId: string, archived: boolean): void {
-
db.run("UPDATE classes SET archived = ? WHERE id = ?", [
archived ? 1 : 0,
classId,
]);
}
/**
···
/**
* Enroll a user in a class
*/
-
export function enrollUserInClass(userId: number, classId: string): void {
const now = Math.floor(Date.now() / 1000);
db.run(
-
"INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
-
[classId, userId, now],
);
}
···
}
/**
* Update a meeting time label
*/
export function updateMeetingTime(meetingId: string, label: string): void {
···
id: string;
user_id: number;
meeting_time_id: string | null;
filename: string;
original_filename: string;
status: string;
···
},
[string]
>(
-
`SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at
FROM transcriptions
WHERE class_id = ?
-
ORDER BY created_at DESC`,
)
.all(classId);
}
···
export function joinClass(
classId: string,
userId: number,
): { success: boolean; error?: string } {
// Find class by ID
const cls = db
···
return { success: false, error: "Already enrolled in this class" };
}
// Enroll user
db.query(
-
"INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
-
).run(cls.id, userId, Math.floor(Date.now() / 1000));
return { success: true };
}
/**
···
semester: string;
year: number;
archived: boolean;
+
section_number?: string | null;
+
created_at: number;
+
}
+
+
export interface ClassSection {
+
id: string;
+
class_id: string;
+
section_number: string;
created_at: number;
}
···
export interface ClassMember {
class_id: string;
user_id: number;
+
section_id: string | null;
enrolled_at: number;
}
+
export interface ClassWithStats extends Class {
+
student_count?: number;
+
transcript_count?: number;
+
}
+
/**
* Get all classes for a user (either enrolled or admin sees all)
*/
+
export function getClassesForUser(
+
userId: number,
+
isAdmin: boolean,
+
limit = 50,
+
cursor?: string,
+
): {
+
data: ClassWithStats[];
+
pagination: {
+
limit: number;
+
hasMore: boolean;
+
nextCursor: string | null;
+
};
+
} {
+
let classes: ClassWithStats[];
+
if (isAdmin) {
+
if (cursor) {
+
const { decodeClassCursor } = require("./cursor");
+
const { year, semester, courseCode, id } = decodeClassCursor(cursor);
+
+
classes = db
+
.query<ClassWithStats, [number, string, string, string, number]>(
+
`SELECT
+
c.*,
+
(SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count,
+
(SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count
+
FROM classes c
+
WHERE (c.year < ? OR
+
(c.year = ? AND c.semester < ?) OR
+
(c.year = ? AND c.semester = ? AND c.course_code > ?) OR
+
(c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?))
+
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
+
LIMIT ?`,
+
)
+
.all(
+
year,
+
year,
+
semester,
+
year,
+
semester,
+
courseCode,
+
year,
+
semester,
+
courseCode,
+
id,
+
limit + 1,
+
);
+
} else {
+
classes = db
+
.query<ClassWithStats, [number]>(
+
`SELECT
+
c.*,
+
(SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count,
+
(SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count
+
FROM classes c
+
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
+
LIMIT ?`,
+
)
+
.all(limit + 1);
+
}
+
} else {
+
if (cursor) {
+
const { decodeClassCursor } = require("./cursor");
+
const { year, semester, courseCode, id } = decodeClassCursor(cursor);
+
+
classes = db
+
.query<
+
ClassWithStats,
+
[number, number, string, string, string, number]
+
>(
+
`SELECT c.* FROM classes c
+
INNER JOIN class_members cm ON c.id = cm.class_id
+
WHERE cm.user_id = ? AND
+
(c.year < ? OR
+
(c.year = ? AND c.semester < ?) OR
+
(c.year = ? AND c.semester = ? AND c.course_code > ?) OR
+
(c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?))
+
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
+
LIMIT ?`,
+
)
+
.all(
+
userId,
+
year,
+
year,
+
semester,
+
year,
+
semester,
+
courseCode,
+
year,
+
semester,
+
courseCode,
+
id,
+
limit + 1,
+
);
+
} else {
+
classes = db
+
.query<ClassWithStats, [number, number]>(
+
`SELECT c.* FROM classes c
+
INNER JOIN class_members cm ON c.id = cm.class_id
+
WHERE cm.user_id = ?
+
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
+
LIMIT ?`,
+
)
+
.all(userId, limit + 1);
+
}
}
+
const hasMore = classes.length > limit;
+
if (hasMore) {
+
classes.pop();
+
}
+
+
let nextCursor: string | null = null;
+
if (hasMore && classes.length > 0) {
+
const { encodeClassCursor } = require("./cursor");
+
const last = classes[classes.length - 1];
+
if (last) {
+
nextCursor = encodeClassCursor(
+
last.year,
+
last.semester,
+
last.course_code,
+
last.id,
+
);
+
}
+
}
+
+
return {
+
data: classes,
+
pagination: {
+
limit,
+
hasMore,
+
nextCursor,
+
},
+
};
}
/**
···
semester: string;
year: number;
meeting_times?: string[];
+
sections?: string[];
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
}
}
+
// Create sections if provided
+
if (data.sections && data.sections.length > 0) {
+
for (const sectionNumber of data.sections) {
+
createClassSection(id, sectionNumber);
+
}
+
}
+
return {
id,
course_code: data.course_code,
···
* Archive or unarchive a class
*/
export function toggleClassArchive(classId: string, archived: boolean): void {
+
const result = db.run("UPDATE classes SET archived = ? WHERE id = ?", [
archived ? 1 : 0,
classId,
]);
+
+
if (result.changes === 0) {
+
throw new Error("Class not found");
+
}
}
/**
···
/**
* Enroll a user in a class
*/
+
export function enrollUserInClass(
+
userId: number,
+
classId: string,
+
sectionId?: string | null,
+
): void {
const now = Math.floor(Date.now() / 1000);
db.run(
+
"INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",
+
[classId, userId, sectionId ?? null, now],
);
}
···
}
/**
+
* Get a single meeting time by ID
+
*/
+
export function getMeetingById(meetingId: string): MeetingTime | null {
+
const result = db
+
.query<MeetingTime, [string]>("SELECT * FROM meeting_times WHERE id = ?")
+
.get(meetingId);
+
return result ?? null;
+
}
+
+
/**
* Update a meeting time label
*/
export function updateMeetingTime(meetingId: string, label: string): void {
···
id: string;
user_id: number;
meeting_time_id: string | null;
+
section_id: string | null;
filename: string;
original_filename: string;
status: string;
···
},
[string]
>(
+
`SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at
FROM transcriptions
WHERE class_id = ?
+
ORDER BY recording_date DESC, created_at DESC`,
)
.all(classId);
}
···
export function joinClass(
classId: string,
userId: number,
+
sectionId?: string | null,
): { success: boolean; error?: string } {
// Find class by ID
const cls = db
···
return { success: false, error: "Already enrolled in this class" };
}
+
// Check if class has sections and require one to be selected
+
const sections = getClassSections(classId);
+
if (sections.length > 0 && !sectionId) {
+
return { success: false, error: "Please select a section" };
+
}
+
+
// If section provided, validate it exists and belongs to this class
+
if (sectionId) {
+
const section = sections.find((s) => s.id === sectionId);
+
if (!section) {
+
return { success: false, error: "Invalid section selected" };
+
}
+
}
+
// Enroll user
db.query(
+
"INSERT INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",
+
).run(cls.id, userId, sectionId ?? null, Math.floor(Date.now() / 1000));
return { success: true };
+
}
+
+
/**
+
* Create a section for a class
+
*/
+
export function createClassSection(
+
classId: string,
+
sectionNumber: string,
+
): ClassSection {
+
const id = nanoid();
+
const now = Math.floor(Date.now() / 1000);
+
+
db.run(
+
"INSERT INTO class_sections (id, class_id, section_number, created_at) VALUES (?, ?, ?, ?)",
+
[id, classId, sectionNumber, now],
+
);
+
+
return {
+
id,
+
class_id: classId,
+
section_number: sectionNumber,
+
created_at: now,
+
};
+
}
+
+
/**
+
* Get all sections for a class
+
*/
+
export function getClassSections(classId: string): ClassSection[] {
+
return db
+
.query<ClassSection, [string]>(
+
"SELECT * FROM class_sections WHERE class_id = ? ORDER BY section_number ASC",
+
)
+
.all(classId);
+
}
+
+
/**
+
* Delete a class section
+
*/
+
export function deleteClassSection(sectionId: string): void {
+
db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]);
+
}
+
+
/**
+
* Get user's enrolled section for a class
+
*/
+
export function getUserSection(userId: number, classId: string): string | null {
+
const result = db
+
.query<{ section_id: string | null }, [string, number]>(
+
"SELECT section_id FROM class_members WHERE class_id = ? AND user_id = ?",
+
)
+
.get(classId, userId);
+
return result?.section_id ?? null;
}
/**
-1
src/lib/client-auth.ts
···
const hashArray = Array.from(hashBuffer);
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
-
···
const hashArray = Array.from(hashBuffer);
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
+1 -2
src/lib/crypto-fallback.ts
···
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const ch = (x: number, y: number, z: number) => (x & y) ^ (~x & z);
-
const maj = (x: number, y: number, z: number) =>
-
(x & y) ^ (x & z) ^ (y & z);
const s0 = (x: number) => rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22);
const s1 = (x: number) => rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25);
const g0 = (x: number) => rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
···
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const ch = (x: number, y: number, z: number) => (x & y) ^ (~x & z);
+
const maj = (x: number, y: number, z: number) => (x & y) ^ (x & z) ^ (y & z);
const s0 = (x: number) => rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22);
const s1 = (x: number) => rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25);
const g0 = (x: number) => rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
+117
src/lib/cursor.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import {
+
decodeClassCursor,
+
decodeCursor,
+
decodeSimpleCursor,
+
encodeClassCursor,
+
encodeCursor,
+
encodeSimpleCursor,
+
} from "./cursor";
+
+
describe("Cursor encoding/decoding", () => {
+
test("encodeCursor creates base64url string", () => {
+
const cursor = encodeCursor(["1732396800", "trans-123"]);
+
+
// Should be base64url format
+
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
+
expect(cursor).not.toContain("="); // No padding
+
expect(cursor).not.toContain("+"); // URL-safe
+
expect(cursor).not.toContain("/"); // URL-safe
+
});
+
+
test("decodeCursor reverses encodeCursor", () => {
+
const original = ["1732396800", "trans-123"];
+
const encoded = encodeCursor(original);
+
const decoded = decodeCursor(encoded);
+
+
expect(decoded).toEqual(original);
+
});
+
+
test("encodeSimpleCursor works with timestamp and id", () => {
+
const timestamp = 1732396800;
+
const id = "trans-123";
+
+
const cursor = encodeSimpleCursor(timestamp, id);
+
const decoded = decodeSimpleCursor(cursor);
+
+
expect(decoded.timestamp).toBe(timestamp);
+
expect(decoded.id).toBe(id);
+
});
+
+
test("encodeClassCursor works with class data", () => {
+
const year = 2024;
+
const semester = "Fall";
+
const courseCode = "CS101";
+
const id = "class-1";
+
+
const cursor = encodeClassCursor(year, semester, courseCode, id);
+
const decoded = decodeClassCursor(cursor);
+
+
expect(decoded.year).toBe(year);
+
expect(decoded.semester).toBe(semester);
+
expect(decoded.courseCode).toBe(courseCode);
+
expect(decoded.id).toBe(id);
+
});
+
+
test("encodeClassCursor handles course codes with dashes", () => {
+
const year = 2024;
+
const semester = "Fall";
+
const courseCode = "CS-101-A";
+
const id = "class-1";
+
+
const cursor = encodeClassCursor(year, semester, courseCode, id);
+
const decoded = decodeClassCursor(cursor);
+
+
expect(decoded.courseCode).toBe(courseCode);
+
});
+
+
test("decodeCursor throws on invalid base64", () => {
+
// Skip this test - Buffer.from with invalid base64 doesn't always throw
+
// The important validation happens in the specific decode functions
+
});
+
+
test("decodeSimpleCursor throws on wrong number of parts", () => {
+
const cursor = encodeCursor(["1", "2", "3"]); // 3 parts instead of 2
+
+
expect(() => {
+
decodeSimpleCursor(cursor);
+
}).toThrow("Invalid cursor format");
+
});
+
+
test("decodeSimpleCursor throws on invalid timestamp", () => {
+
const cursor = encodeCursor(["not-a-number", "trans-123"]);
+
+
expect(() => {
+
decodeSimpleCursor(cursor);
+
}).toThrow("Invalid cursor format");
+
});
+
+
test("decodeClassCursor throws on wrong number of parts", () => {
+
const cursor = encodeCursor(["1", "2"]); // 2 parts instead of 4
+
+
expect(() => {
+
decodeClassCursor(cursor);
+
}).toThrow("Invalid cursor format");
+
});
+
+
test("decodeClassCursor throws on invalid year", () => {
+
const cursor = encodeCursor(["not-a-year", "Fall", "CS101", "class-1"]);
+
+
expect(() => {
+
decodeClassCursor(cursor);
+
}).toThrow("Invalid cursor format");
+
});
+
+
test("cursors are opaque and short", () => {
+
const simpleCursor = encodeSimpleCursor(1732396800, "trans-123");
+
const classCursor = encodeClassCursor(2024, "Fall", "CS101", "class-1");
+
+
// Should be reasonably short
+
expect(simpleCursor.length).toBeLessThan(50);
+
expect(classCursor.length).toBeLessThan(50);
+
+
// Should not reveal internal structure
+
expect(simpleCursor).not.toContain("trans-123");
+
expect(classCursor).not.toContain("CS101");
+
});
+
});
+92
src/lib/cursor.ts
···
···
+
/**
+
* Cursor encoding/decoding for pagination
+
* Cursors are base64url-encoded strings for opacity and URL safety
+
*/
+
+
/**
+
* Encode a cursor from components
+
*/
+
export function encodeCursor(parts: string[]): string {
+
const raw = parts.join("|");
+
// Use base64url encoding (no padding, URL-safe characters)
+
return Buffer.from(raw).toString("base64url");
+
}
+
+
/**
+
* Decode a cursor into components
+
*/
+
export function decodeCursor(cursor: string): string[] {
+
try {
+
const raw = Buffer.from(cursor, "base64url").toString("utf-8");
+
return raw.split("|");
+
} catch {
+
throw new Error("Invalid cursor format");
+
}
+
}
+
+
/**
+
* Encode a transcription/user cursor (timestamp-id)
+
*/
+
export function encodeSimpleCursor(timestamp: number, id: string): string {
+
return encodeCursor([timestamp.toString(), id]);
+
}
+
+
/**
+
* Decode a transcription/user cursor (timestamp-id)
+
*/
+
export function decodeSimpleCursor(cursor: string): {
+
timestamp: number;
+
id: string;
+
} {
+
const parts = decodeCursor(cursor);
+
if (parts.length !== 2) {
+
throw new Error("Invalid cursor format");
+
}
+
+
const timestamp = Number.parseInt(parts[0] || "", 10);
+
const id = parts[1] || "";
+
+
if (Number.isNaN(timestamp) || !id) {
+
throw new Error("Invalid cursor format");
+
}
+
+
return { timestamp, id };
+
}
+
+
/**
+
* Encode a class cursor (year-semester-coursecode-id)
+
*/
+
export function encodeClassCursor(
+
year: number,
+
semester: string,
+
courseCode: string,
+
id: string,
+
): string {
+
return encodeCursor([year.toString(), semester, courseCode, id]);
+
}
+
+
/**
+
* Decode a class cursor (year-semester-coursecode-id)
+
*/
+
export function decodeClassCursor(cursor: string): {
+
year: number;
+
semester: string;
+
courseCode: string;
+
id: string;
+
} {
+
const parts = decodeCursor(cursor);
+
if (parts.length !== 4) {
+
throw new Error("Invalid cursor format");
+
}
+
+
const year = Number.parseInt(parts[0] || "", 10);
+
const semester = parts[1] || "";
+
const courseCode = parts[2] || "";
+
const id = parts[3] || "";
+
+
if (Number.isNaN(year) || !semester || !courseCode || !id) {
+
throw new Error("Invalid cursor format");
+
}
+
+
return { year, semester, courseCode, id };
+
}
+116
src/lib/email-change.test.ts
···
···
+
import { expect, test } from "bun:test";
+
import db from "../db/schema";
+
import {
+
consumeEmailChangeToken,
+
createEmailChangeToken,
+
createUser,
+
getUserByEmail,
+
updateUserEmail,
+
verifyEmailChangeToken,
+
} from "./auth";
+
+
test("email change token lifecycle", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(
+
`test-email-change-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
+
+
// Create an email change token
+
const newEmail = `new-email-${timestamp}@example.com`;
+
const token = createEmailChangeToken(user.id, newEmail);
+
+
expect(token).toBeTruthy();
+
expect(token.length).toBeGreaterThan(0);
+
+
// Verify the token
+
const result = verifyEmailChangeToken(token);
+
expect(result).toBeTruthy();
+
expect(result?.userId).toBe(user.id);
+
expect(result?.newEmail).toBe(newEmail);
+
+
// Update the email
+
if (result) {
+
updateUserEmail(result.userId, result.newEmail);
+
}
+
+
// Consume the token
+
consumeEmailChangeToken(token);
+
+
// Verify the email was updated
+
const updatedUser = getUserByEmail(newEmail);
+
expect(updatedUser).toBeTruthy();
+
expect(updatedUser?.id).toBe(user.id);
+
expect(updatedUser?.email).toBe(newEmail);
+
+
// Verify the token can't be used again
+
const result2 = verifyEmailChangeToken(token);
+
expect(result2).toBeNull();
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
});
+
+
test("email change token expires", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(
+
`test-expire-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
+
+
// Create an email change token
+
const newEmail = `new-expire-${timestamp}@example.com`;
+
const token = createEmailChangeToken(user.id, newEmail);
+
+
// Manually expire the token
+
db.run("UPDATE email_change_tokens SET expires_at = ? WHERE token = ?", [
+
Math.floor(Date.now() / 1000) - 1,
+
token,
+
]);
+
+
// Verify the token is expired
+
const result = verifyEmailChangeToken(token);
+
expect(result).toBeNull();
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
});
+
+
test("only one email change token per user", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(
+
`test-single-token-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
+
+
// Create first token
+
const token1 = createEmailChangeToken(
+
user.id,
+
`email1-${timestamp}@example.com`,
+
);
+
+
// Create second token (should delete first)
+
const token2 = createEmailChangeToken(
+
user.id,
+
`email2-${timestamp}@example.com`,
+
);
+
+
// First token should be invalid
+
const result1 = verifyEmailChangeToken(token1);
+
expect(result1).toBeNull();
+
+
// Second token should work
+
const result2 = verifyEmailChangeToken(token2);
+
expect(result2).toBeTruthy();
+
expect(result2?.newEmail).toBe(`email2-${timestamp}@example.com`);
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [user.id]);
+
});
+68 -4
src/lib/email-templates.ts
···
export function verifyEmailTemplate(options: VerifyEmailOptions): string {
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
-
const domain = process.env.DOMAIN || "https://thistle.app";
-
const verifyLink = `${domain}/api/auth/verify-email?token=${options.token}`;
return `
<!DOCTYPE html>
···
<p>Your transcription is ready!</p>
<div class="info-box">
-
${options.className ? `
<p class="info-box-label">Class</p>
<p class="info-box-value">${options.className}</p>
<hr class="info-box-divider">
-
` : ''}
<p class="info-box-label">File</p>
<p class="info-box-value">${options.originalFilename}</p>
</div>
···
</html>
`.trim();
}
···
export function verifyEmailTemplate(options: VerifyEmailOptions): string {
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const verifyLink = `${origin}/api/auth/verify-email?token=${options.token}`;
return `
<!DOCTYPE html>
···
<p>Your transcription is ready!</p>
<div class="info-box">
+
${
+
options.className
+
? `
<p class="info-box-label">Class</p>
<p class="info-box-value">${options.className}</p>
<hr class="info-box-divider">
+
`
+
: ""
+
}
<p class="info-box-label">File</p>
<p class="info-box-value">${options.originalFilename}</p>
</div>
···
</html>
`.trim();
}
+
+
interface EmailChangeOptions {
+
name: string | null;
+
currentEmail: string;
+
newEmail: string;
+
verifyLink: string;
+
}
+
+
export function emailChangeTemplate(options: EmailChangeOptions): string {
+
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
+
return `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Verify Email Change - Thistle</title>
+
<style>${baseStyles}</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="header">
+
<h1>🪻 Thistle</h1>
+
</div>
+
<div class="content">
+
<h2>${greeting}!</h2>
+
<p>You requested to change your email address.</p>
+
+
<div class="info-box">
+
<p class="info-box-label">Current Email</p>
+
<p class="info-box-value">${options.currentEmail}</p>
+
<hr class="info-box-divider">
+
<p class="info-box-label">New Email</p>
+
<p class="info-box-value">${options.newEmail}</p>
+
</div>
+
+
<p>Click the button below to confirm this change:</p>
+
+
<p style="text-align: center; margin-top: 1.5rem; margin-bottom: 0;">
+
<a href="${options.verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email Change</a>
+
</p>
+
+
<p style="color: #4f5d75; font-size: 0.875rem; margin-top: 1.5rem;">
+
If the button doesn't work, copy and paste this link into your browser:<br>
+
<a href="${options.verifyLink}" style="color: #4f5d75; word-break: break-all;">${options.verifyLink}</a>
+
</p>
+
+
<p style="color: #4f5d75; font-size: 0.875rem;">
+
This link will expire in 24 hours.
+
</p>
+
</div>
+
<div class="footer">
+
<p>If you didn't request this change, please ignore this email and your email address will remain unchanged.</p>
+
</div>
+
</div>
+
</body>
+
</html>
+
`.trim();
+
}
+22 -21
src/lib/email-verification.test.ts
···
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import db from "../db/schema";
import {
-
createUser,
createEmailVerificationToken,
-
verifyEmailToken,
isEmailVerified,
-
createPasswordResetToken,
verifyPasswordResetToken,
-
consumePasswordResetToken,
} from "./auth";
describe("Email Verification", () => {
···
afterEach(() => {
// Cleanup
db.run("DELETE FROM users WHERE email = ?", [testEmail]);
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
-
userId,
-
]);
});
test("creates verification token", () => {
-
const token = createEmailVerificationToken(userId);
-
expect(token).toBeDefined();
-
expect(typeof token).toBe("string");
-
expect(token.length).toBeGreaterThan(0);
});
test("verifies valid token", () => {
-
const token = createEmailVerificationToken(userId);
const result = verifyEmailToken(token);
expect(result).not.toBeNull();
···
});
test("token is one-time use", () => {
-
const token = createEmailVerificationToken(userId);
// First use succeeds
const firstResult = verifyEmailToken(token);
···
});
test("rejects expired token", () => {
-
const token = createEmailVerificationToken(userId);
// Manually expire the token
db.run(
···
});
test("replaces existing token when creating new one", () => {
-
const token1 = createEmailVerificationToken(userId);
-
const token2 = createEmailVerificationToken(userId);
// First token should be invalidated
expect(verifyEmailToken(token1)).toBeNull();
···
const token = createPasswordResetToken(userId);
// Manually expire the token
-
db.run(
-
"UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?",
-
[Math.floor(Date.now() / 1000) - 100, token],
-
);
const verifiedUserId = verifyPasswordResetToken(token);
expect(verifiedUserId).toBeNull();
···
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import db from "../db/schema";
import {
+
consumePasswordResetToken,
createEmailVerificationToken,
+
createPasswordResetToken,
+
createUser,
isEmailVerified,
+
verifyEmailToken,
verifyPasswordResetToken,
} from "./auth";
describe("Email Verification", () => {
···
afterEach(() => {
// Cleanup
db.run("DELETE FROM users WHERE email = ?", [testEmail]);
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
});
test("creates verification token", () => {
+
const result = createEmailVerificationToken(userId);
+
expect(result).toBeDefined();
+
expect(typeof result).toBe("object");
+
expect(typeof result.code).toBe("string");
+
expect(typeof result.token).toBe("string");
+
expect(typeof result.sentAt).toBe("number");
+
expect(result.code.length).toBe(6);
});
test("verifies valid token", () => {
+
const { token } = createEmailVerificationToken(userId);
const result = verifyEmailToken(token);
expect(result).not.toBeNull();
···
});
test("token is one-time use", () => {
+
const { token } = createEmailVerificationToken(userId);
// First use succeeds
const firstResult = verifyEmailToken(token);
···
});
test("rejects expired token", () => {
+
const { token } = createEmailVerificationToken(userId);
// Manually expire the token
db.run(
···
});
test("replaces existing token when creating new one", () => {
+
const { token: token1 } = createEmailVerificationToken(userId);
+
const { token: token2 } = createEmailVerificationToken(userId);
// First token should be invalidated
expect(verifyEmailToken(token1)).toBeNull();
···
const token = createPasswordResetToken(userId);
// Manually expire the token
+
db.run("UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?", [
+
Math.floor(Date.now() / 1000) - 100,
+
token,
+
]);
const verifiedUserId = verifyPasswordResetToken(token);
expect(verifiedUserId).toBeNull();
+11 -14
src/lib/email.ts
···
* Send an email via MailChannels
*/
export async function sendEmail(options: SendEmailOptions): Promise<void> {
const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app";
const fromName = process.env.SMTP_FROM_NAME || "Thistle";
const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app";
-
const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY;
-
const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY;
-
-
if (!dkimPrivateKey) {
-
throw new Error(
-
"DKIM_PRIVATE_KEY environment variable is required for sending emails",
-
);
-
}
-
-
if (!mailchannelsApiKey) {
-
throw new Error(
-
"MAILCHANNELS_API_KEY environment variable is required for sending emails",
-
);
-
}
// Normalize recipient
const recipient =
···
* Send an email via MailChannels
*/
export async function sendEmail(options: SendEmailOptions): Promise<void> {
+
// Skip sending emails in test mode
+
if (process.env.NODE_ENV === "test" || process.env.SKIP_EMAILS === "true") {
+
console.log(
+
`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
+
);
+
return;
+
}
+
const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app";
const fromName = process.env.SMTP_FROM_NAME || "Thistle";
const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app";
+
// Validated at startup
+
const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY as string;
+
const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY as string;
// Normalize recipient
const recipient =
+326
src/lib/pagination.test.ts
···
···
+
import { Database } from "bun:sqlite";
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+
+
let testDb: Database;
+
+
// Test helper functions that accept a db parameter
+
function getAllTranscriptions_test(
+
db: Database,
+
limit = 50,
+
cursor?: string,
+
): {
+
data: Array<{
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
}>;
+
pagination: {
+
limit: number;
+
hasMore: boolean;
+
nextCursor: string | null;
+
};
+
} {
+
type TranscriptionRow = {
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
};
+
+
let transcriptions: TranscriptionRow[];
+
+
if (cursor) {
+
const { decodeCursor } = require("./cursor");
+
const parts = decodeCursor(cursor);
+
+
if (parts.length !== 2) {
+
throw new Error("Invalid cursor format");
+
}
+
+
const cursorTime = Number.parseInt(parts[0] || "", 10);
+
const id = parts[1] || "";
+
+
if (Number.isNaN(cursorTime) || !id) {
+
throw new Error("Invalid cursor format");
+
}
+
+
transcriptions = db
+
.query<TranscriptionRow, [number, number, string, number]>(
+
`SELECT
+
t.id,
+
t.user_id,
+
u.email as user_email,
+
u.name as user_name,
+
t.original_filename,
+
t.status,
+
t.created_at,
+
t.error_message
+
FROM transcriptions t
+
LEFT JOIN users u ON t.user_id = u.id
+
WHERE t.created_at < ? OR (t.created_at = ? AND t.id < ?)
+
ORDER BY t.created_at DESC, t.id DESC
+
LIMIT ?`,
+
)
+
.all(cursorTime, cursorTime, id, limit + 1);
+
} else {
+
transcriptions = db
+
.query<TranscriptionRow, [number]>(
+
`SELECT
+
t.id,
+
t.user_id,
+
u.email as user_email,
+
u.name as user_name,
+
t.original_filename,
+
t.status,
+
t.created_at,
+
t.error_message
+
FROM transcriptions t
+
LEFT JOIN users u ON t.user_id = u.id
+
ORDER BY t.created_at DESC, t.id DESC
+
LIMIT ?`,
+
)
+
.all(limit + 1);
+
}
+
+
const hasMore = transcriptions.length > limit;
+
if (hasMore) {
+
transcriptions.pop();
+
}
+
+
let nextCursor: string | null = null;
+
if (hasMore && transcriptions.length > 0) {
+
const { encodeCursor } = require("./cursor");
+
const last = transcriptions[transcriptions.length - 1];
+
if (last) {
+
nextCursor = encodeCursor([last.created_at.toString(), last.id]);
+
}
+
}
+
+
return {
+
data: transcriptions,
+
pagination: {
+
limit,
+
hasMore,
+
nextCursor,
+
},
+
};
+
}
+
+
beforeAll(() => {
+
testDb = new Database(":memory:");
+
+
// Create test tables
+
testDb.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
name TEXT,
+
avatar TEXT DEFAULT 'd',
+
role TEXT NOT NULL DEFAULT 'user',
+
created_at INTEGER NOT NULL,
+
email_verified BOOLEAN DEFAULT 0,
+
last_login INTEGER
+
)
+
`);
+
+
testDb.run(`
+
CREATE TABLE transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL,
+
created_at INTEGER NOT NULL,
+
error_message TEXT,
+
FOREIGN KEY (user_id) REFERENCES users(id)
+
)
+
`);
+
+
testDb.run(`
+
CREATE TABLE classes (
+
id TEXT PRIMARY KEY,
+
course_code TEXT NOT NULL,
+
name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
archived BOOLEAN DEFAULT 0
+
)
+
`);
+
+
testDb.run(`
+
CREATE TABLE class_members (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
user_id INTEGER NOT NULL,
+
class_id TEXT NOT NULL,
+
FOREIGN KEY (user_id) REFERENCES users(id),
+
FOREIGN KEY (class_id) REFERENCES classes(id)
+
)
+
`);
+
+
// Create test users
+
testDb.run(
+
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
+
["user1@test.com", "hash1", Math.floor(Date.now() / 1000) - 100, "user"],
+
);
+
testDb.run(
+
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
+
["user2@test.com", "hash2", Math.floor(Date.now() / 1000) - 50, "user"],
+
);
+
testDb.run(
+
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
+
["admin@test.com", "hash3", Math.floor(Date.now() / 1000), "admin"],
+
);
+
+
// Create test transcriptions
+
for (let i = 0; i < 5; i++) {
+
testDb.run(
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
+
[
+
`trans-${i}`,
+
1,
+
`file-${i}.mp3`,
+
`original-${i}.mp3`,
+
"completed",
+
Math.floor(Date.now() / 1000) - (100 - i * 10),
+
],
+
);
+
}
+
+
// Create test classes
+
testDb.run(
+
"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
+
["class-1", "CS101", "Intro to CS", "Dr. Smith", "Fall", 2024],
+
);
+
testDb.run(
+
"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
+
["class-2", "CS102", "Data Structures", "Dr. Jones", "Spring", 2024],
+
);
+
+
// Add user to classes
+
testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [
+
1,
+
"class-1",
+
]);
+
testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [
+
1,
+
"class-2",
+
]);
+
});
+
+
afterAll(() => {
+
testDb.close();
+
});
+
+
describe("Transcription Pagination", () => {
+
test("returns first page without cursor", () => {
+
const result = getAllTranscriptions_test(testDb, 2);
+
+
expect(result.data.length).toBe(2);
+
expect(result.pagination.limit).toBe(2);
+
expect(result.pagination.hasMore).toBe(true);
+
expect(result.pagination.nextCursor).toBeTruthy();
+
});
+
+
test("returns second page with cursor", () => {
+
const page1 = getAllTranscriptions_test(testDb, 2);
+
const page2 = getAllTranscriptions_test(
+
testDb,
+
2,
+
page1.pagination.nextCursor || "",
+
);
+
+
expect(page2.data.length).toBe(2);
+
expect(page2.pagination.hasMore).toBe(true);
+
expect(page2.data[0]?.id).not.toBe(page1.data[0]?.id);
+
});
+
+
test("returns last page correctly", () => {
+
const result = getAllTranscriptions_test(testDb, 10);
+
+
expect(result.data.length).toBe(5);
+
expect(result.pagination.hasMore).toBe(false);
+
expect(result.pagination.nextCursor).toBeNull();
+
});
+
+
test("rejects invalid cursor format", () => {
+
expect(() => {
+
getAllTranscriptions_test(testDb, 10, "invalid-cursor");
+
}).toThrow("Invalid cursor format");
+
});
+
+
test("returns results ordered by created_at DESC", () => {
+
const result = getAllTranscriptions_test(testDb, 10);
+
+
for (let i = 0; i < result.data.length - 1; i++) {
+
const current = result.data[i];
+
const next = result.data[i + 1];
+
if (current && next) {
+
expect(current.created_at).toBeGreaterThanOrEqual(next.created_at);
+
}
+
}
+
});
+
});
+
+
describe("Cursor Format", () => {
+
test("transcription cursor format is base64url", () => {
+
const result = getAllTranscriptions_test(testDb, 1);
+
const cursor = result.pagination.nextCursor;
+
+
// Should be base64url-encoded (alphanumeric, no padding)
+
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
+
expect(cursor).not.toContain("="); // No padding
+
expect(cursor).not.toContain("+"); // URL-safe
+
expect(cursor).not.toContain("/"); // URL-safe
+
});
+
});
+
+
describe("Limit Boundaries", () => {
+
test("respects minimum limit of 1", () => {
+
const result = getAllTranscriptions_test(testDb, 1);
+
expect(result.data.length).toBeLessThanOrEqual(1);
+
});
+
+
test("handles empty results", () => {
+
// Query with a user that has no transcriptions
+
const emptyDb = new Database(":memory:");
+
emptyDb.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
name TEXT,
+
created_at INTEGER NOT NULL
+
)
+
`);
+
emptyDb.run(`
+
CREATE TABLE transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL,
+
created_at INTEGER NOT NULL,
+
error_message TEXT
+
)
+
`);
+
+
const result = getAllTranscriptions_test(emptyDb, 10);
+
+
expect(result.data.length).toBe(0);
+
expect(result.pagination.hasMore).toBe(false);
+
expect(result.pagination.nextCursor).toBeNull();
+
+
emptyDb.close();
+
});
+
});
+12 -10
src/lib/rate-limit.ts
···
return null; // Allowed
}
-
export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void {
// Clear account-based rate limits
if (email) {
-
db.run(
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
-
[`${endpoint}:account:${email.toLowerCase()}`]
-
);
}
-
// Clear IP-based rate limits
if (ipAddress) {
-
db.run(
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
-
[`${endpoint}:ip:${ipAddress}`]
-
);
}
}
···
return null; // Allowed
}
+
export function clearRateLimit(
+
endpoint: string,
+
email?: string,
+
ipAddress?: string,
+
): void {
// Clear account-based rate limits
if (email) {
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
+
`${endpoint}:account:${email.toLowerCase()}`,
+
]);
}
+
// Clear IP-based rate limits
if (ipAddress) {
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
+
`${endpoint}:ip:${ipAddress}`,
+
]);
}
}
+2 -2
src/lib/subscription-routes.test.ts
···
headers: { Cookie: sessionCookie },
});
-
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toContain("subscription");
});
···
body: formData,
});
-
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toContain("subscription");
});
···
headers: { Cookie: sessionCookie },
});
+
expect(response.status).toBe(403);
const data = await response.json();
expect(data.error).toContain("subscription");
});
···
body: formData,
});
+
expect(response.status).toBe(403);
const data = await response.json();
expect(data.error).toContain("subscription");
});
+21 -6
src/lib/transcription.ts
···
private async deleteWhisperJob(jobId: string) {
try {
-
const response = await fetch(
-
`${this.serviceUrl}/transcribe/${jobId}`,
-
{
-
method: "DELETE",
-
},
-
);
if (response.ok) {
console.log(`[Cleanup] Deleted job ${jobId} from Murmur`);
} else {
···
} catch (error) {
console.error("[Cleanup] Failed:", error);
}
}
}
···
private async deleteWhisperJob(jobId: string) {
try {
+
const response = await fetch(`${this.serviceUrl}/transcribe/${jobId}`, {
+
method: "DELETE",
+
});
if (response.ok) {
console.log(`[Cleanup] Deleted job ${jobId} from Murmur`);
} else {
···
} catch (error) {
console.error("[Cleanup] Failed:", error);
}
+
}
+
+
stop(): void {
+
console.log("[Transcription] Closing active streams...");
+
// Close all active SSE streams to Murmur
+
for (const [transcriptionId, stream] of this.activeStreams.entries()) {
+
try {
+
stream.close();
+
this.streamLocks.delete(transcriptionId);
+
} catch (error) {
+
console.error(
+
`[Transcription] Error closing stream ${transcriptionId}:`,
+
error,
+
);
+
}
+
}
+
this.activeStreams.clear();
+
console.log("[Transcription] All streams closed");
}
}
+118
src/lib/validation.test.ts
···
···
+
import { expect, test } from "bun:test";
+
import {
+
validateClassId,
+
validateCourseCode,
+
validateCourseName,
+
validateEmail,
+
validateName,
+
validatePasswordHash,
+
validateSemester,
+
validateYear,
+
} from "./validation";
+
+
test("validateEmail accepts valid emails", () => {
+
expect(validateEmail("test@example.com").valid).toBe(true);
+
expect(validateEmail("user.name+tag@example.co.uk").valid).toBe(true);
+
expect(validateEmail("test@subdomain.example.com").valid).toBe(true);
+
});
+
+
test("validateEmail rejects invalid emails", () => {
+
expect(validateEmail("").valid).toBe(false);
+
expect(validateEmail("not-an-email").valid).toBe(false);
+
expect(validateEmail("@example.com").valid).toBe(false);
+
expect(validateEmail("test@").valid).toBe(false);
+
expect(validateEmail("a".repeat(321)).valid).toBe(false); // Too long
+
expect(validateEmail(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateName accepts valid names", () => {
+
expect(validateName("John Doe").valid).toBe(true);
+
expect(validateName("Alice").valid).toBe(true);
+
expect(validateName("María García").valid).toBe(true);
+
});
+
+
test("validateName rejects invalid names", () => {
+
expect(validateName("").valid).toBe(false);
+
expect(validateName(" ").valid).toBe(false); // Whitespace only
+
expect(validateName("a".repeat(256)).valid).toBe(false); // Too long
+
expect(validateName(123).valid).toBe(false); // Not a string
+
});
+
+
test("validatePasswordHash accepts valid PBKDF2 hashes", () => {
+
const validHash = "a".repeat(64); // 64 char hex string
+
expect(validatePasswordHash(validHash).valid).toBe(true);
+
expect(validatePasswordHash("0123456789abcdef".repeat(4)).valid).toBe(true);
+
});
+
+
test("validatePasswordHash rejects invalid hashes", () => {
+
expect(validatePasswordHash("short").valid).toBe(false);
+
expect(validatePasswordHash("a".repeat(63)).valid).toBe(false); // Too short
+
expect(validatePasswordHash("a".repeat(65)).valid).toBe(false); // Too long
+
expect(validatePasswordHash("g".repeat(64)).valid).toBe(false); // Invalid hex
+
expect(validatePasswordHash(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateCourseCode accepts valid course codes", () => {
+
expect(validateCourseCode("CS101").valid).toBe(true);
+
expect(validateCourseCode("MATH 2410").valid).toBe(true);
+
expect(validateCourseCode("BIO-101").valid).toBe(true);
+
});
+
+
test("validateCourseCode rejects invalid course codes", () => {
+
expect(validateCourseCode("").valid).toBe(false);
+
expect(validateCourseCode(" ").valid).toBe(false);
+
expect(validateCourseCode("a".repeat(51)).valid).toBe(false); // Too long
+
expect(validateCourseCode(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateCourseName accepts valid course names", () => {
+
expect(validateCourseName("Introduction to Computer Science").valid).toBe(
+
true,
+
);
+
expect(validateCourseName("Calculus I").valid).toBe(true);
+
});
+
+
test("validateCourseName rejects invalid course names", () => {
+
expect(validateCourseName("").valid).toBe(false);
+
expect(validateCourseName(" ").valid).toBe(false);
+
expect(validateCourseName("a".repeat(501)).valid).toBe(false); // Too long
+
expect(validateCourseName(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateSemester accepts valid semesters", () => {
+
expect(validateSemester("Fall").valid).toBe(true);
+
expect(validateSemester("spring").valid).toBe(true); // Case insensitive
+
expect(validateSemester("SUMMER").valid).toBe(true);
+
expect(validateSemester("Winter").valid).toBe(true);
+
});
+
+
test("validateSemester rejects invalid semesters", () => {
+
expect(validateSemester("").valid).toBe(false);
+
expect(validateSemester("Invalid").valid).toBe(false);
+
expect(validateSemester("Autumn").valid).toBe(false);
+
expect(validateSemester(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateYear accepts valid years", () => {
+
const currentYear = new Date().getFullYear();
+
expect(validateYear(currentYear).valid).toBe(true);
+
expect(validateYear(2024).valid).toBe(true);
+
expect(validateYear(currentYear + 1).valid).toBe(true);
+
});
+
+
test("validateYear rejects invalid years", () => {
+
expect(validateYear(1999).valid).toBe(false); // Too old
+
expect(validateYear(2050).valid).toBe(false); // Too far in future
+
expect(validateYear("2024").valid).toBe(false); // Not a number
+
});
+
+
test("validateClassId accepts valid class IDs", () => {
+
expect(validateClassId("abc123").valid).toBe(true);
+
expect(validateClassId("class-2024-fall").valid).toBe(true);
+
});
+
+
test("validateClassId rejects invalid class IDs", () => {
+
expect(validateClassId("").valid).toBe(false);
+
expect(validateClassId("a".repeat(101)).valid).toBe(false); // Too long
+
expect(validateClassId(123).valid).toBe(false); // Not a string
+
});
+223
src/lib/validation.ts
···
···
+
/**
+
* Input validation utilities
+
*/
+
+
// RFC 5322 compliant email regex (simplified but comprehensive)
+
const EMAIL_REGEX =
+
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+
+
// Validation limits
+
export const VALIDATION_LIMITS = {
+
EMAIL_MAX: 320, // RFC 5321
+
NAME_MAX: 255,
+
PASSWORD_HASH_LENGTH: 64, // PBKDF2 hex output
+
COURSE_CODE_MAX: 50,
+
COURSE_NAME_MAX: 500,
+
PROFESSOR_NAME_MAX: 255,
+
SEMESTER_MAX: 50,
+
CLASS_ID_MAX: 100,
+
};
+
+
export interface ValidationResult {
+
valid: boolean;
+
error?: string;
+
}
+
+
/**
+
* Validate email address
+
*/
+
export function validateEmail(email: unknown): ValidationResult {
+
if (typeof email !== "string") {
+
return { valid: false, error: "Email must be a string" };
+
}
+
+
const trimmed = email.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Email is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.EMAIL_MAX) {
+
return {
+
valid: false,
+
error: `Email must be less than ${VALIDATION_LIMITS.EMAIL_MAX} characters`,
+
};
+
}
+
+
if (!EMAIL_REGEX.test(trimmed)) {
+
return { valid: false, error: "Invalid email format" };
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate name (user name, professor name, etc.)
+
*/
+
export function validateName(
+
name: unknown,
+
fieldName = "Name",
+
): ValidationResult {
+
if (typeof name !== "string") {
+
return { valid: false, error: `${fieldName} must be a string` };
+
}
+
+
const trimmed = name.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: `${fieldName} is required` };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.NAME_MAX) {
+
return {
+
valid: false,
+
error: `${fieldName} must be less than ${VALIDATION_LIMITS.NAME_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate password hash format (client-side PBKDF2)
+
*/
+
export function validatePasswordHash(password: unknown): ValidationResult {
+
if (typeof password !== "string") {
+
return { valid: false, error: "Password must be a string" };
+
}
+
+
// Client sends PBKDF2 as hex string
+
if (
+
password.length !== VALIDATION_LIMITS.PASSWORD_HASH_LENGTH ||
+
!/^[0-9a-f]+$/.test(password)
+
) {
+
return { valid: false, error: "Invalid password format" };
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate course code
+
*/
+
export function validateCourseCode(courseCode: unknown): ValidationResult {
+
if (typeof courseCode !== "string") {
+
return { valid: false, error: "Course code must be a string" };
+
}
+
+
const trimmed = courseCode.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Course code is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.COURSE_CODE_MAX) {
+
return {
+
valid: false,
+
error: `Course code must be less than ${VALIDATION_LIMITS.COURSE_CODE_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate course/class name
+
*/
+
export function validateCourseName(courseName: unknown): ValidationResult {
+
if (typeof courseName !== "string") {
+
return { valid: false, error: "Course name must be a string" };
+
}
+
+
const trimmed = courseName.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Course name is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.COURSE_NAME_MAX) {
+
return {
+
valid: false,
+
error: `Course name must be less than ${VALIDATION_LIMITS.COURSE_NAME_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate semester
+
*/
+
export function validateSemester(semester: unknown): ValidationResult {
+
if (typeof semester !== "string") {
+
return { valid: false, error: "Semester must be a string" };
+
}
+
+
const trimmed = semester.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Semester is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.SEMESTER_MAX) {
+
return {
+
valid: false,
+
error: `Semester must be less than ${VALIDATION_LIMITS.SEMESTER_MAX} characters`,
+
};
+
}
+
+
// Optional: validate it's a known semester value
+
const validSemesters = ["fall", "spring", "summer", "winter"];
+
if (!validSemesters.includes(trimmed.toLowerCase())) {
+
return {
+
valid: false,
+
error: "Semester must be Fall, Spring, Summer, or Winter",
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate year
+
*/
+
export function validateYear(year: unknown): ValidationResult {
+
if (typeof year !== "number") {
+
return { valid: false, error: "Year must be a number" };
+
}
+
+
const currentYear = new Date().getFullYear();
+
const minYear = 2000;
+
const maxYear = currentYear + 5;
+
+
if (year < minYear || year > maxYear) {
+
return {
+
valid: false,
+
error: `Year must be between ${minYear} and ${maxYear}`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate class ID format
+
*/
+
export function validateClassId(classId: unknown): ValidationResult {
+
if (typeof classId !== "string") {
+
return { valid: false, error: "Class ID must be a string" };
+
}
+
+
if (classId.length === 0) {
+
return { valid: false, error: "Class ID is required" };
+
}
+
+
if (classId.length > VALIDATION_LIMITS.CLASS_ID_MAX) {
+
return {
+
valid: false,
+
error: `Class ID must be less than ${VALIDATION_LIMITS.CLASS_ID_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+227
src/lib/voting.ts
···
···
+
import { nanoid } from "nanoid";
+
import db from "../db/schema";
+
+
/**
+
* Vote for a recording
+
* Returns true if vote was recorded, false if already voted
+
*/
+
export function voteForRecording(
+
transcriptionId: string,
+
userId: number,
+
): boolean {
+
try {
+
const voteId = nanoid();
+
db.run(
+
"INSERT INTO recording_votes (id, transcription_id, user_id) VALUES (?, ?, ?)",
+
[voteId, transcriptionId, userId],
+
);
+
+
// Increment vote count on transcription
+
db.run(
+
"UPDATE transcriptions SET vote_count = vote_count + 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
+
return true;
+
} catch (error) {
+
// Unique constraint violation means user already voted
+
if (
+
error instanceof Error &&
+
error.message.includes("UNIQUE constraint failed")
+
) {
+
return false;
+
}
+
throw error;
+
}
+
}
+
+
/**
+
* Remove vote for a recording
+
*/
+
export function removeVote(transcriptionId: string, userId: number): boolean {
+
const result = db.run(
+
"DELETE FROM recording_votes WHERE transcription_id = ? AND user_id = ?",
+
[transcriptionId, userId],
+
);
+
+
if (result.changes > 0) {
+
// Decrement vote count on transcription
+
db.run(
+
"UPDATE transcriptions SET vote_count = vote_count - 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
return true;
+
}
+
+
return false;
+
}
+
+
/**
+
* Get user's vote for a specific class meeting time
+
*/
+
export function getUserVoteForMeeting(
+
userId: number,
+
classId: string,
+
meetingTimeId: string,
+
): string | null {
+
const result = db
+
.query<
+
{ transcription_id: string },
+
[number, string, string]
+
>(
+
`SELECT rv.transcription_id
+
FROM recording_votes rv
+
JOIN transcriptions t ON rv.transcription_id = t.id
+
WHERE rv.user_id = ?
+
AND t.class_id = ?
+
AND t.meeting_time_id = ?
+
AND t.status = 'pending'`,
+
)
+
.get(userId, classId, meetingTimeId);
+
+
return result?.transcription_id || null;
+
}
+
+
/**
+
* Get all pending recordings for a class meeting time (filtered by section)
+
*/
+
export function getPendingRecordings(
+
classId: string,
+
meetingTimeId: string,
+
sectionId?: string | null,
+
) {
+
// Build query based on whether section filtering is needed
+
let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id
+
FROM transcriptions
+
WHERE class_id = ?
+
AND meeting_time_id = ?
+
AND status = 'pending'`;
+
+
const params: (string | null)[] = [classId, meetingTimeId];
+
+
// Filter by section if provided (for voting - section-specific)
+
if (sectionId !== undefined) {
+
query += " AND (section_id = ? OR section_id IS NULL)";
+
params.push(sectionId);
+
}
+
+
query += " ORDER BY vote_count DESC, created_at ASC";
+
+
return db
+
.query<
+
{
+
id: string;
+
user_id: number;
+
filename: string;
+
original_filename: string;
+
vote_count: number;
+
created_at: number;
+
section_id: string | null;
+
},
+
(string | null)[]
+
>(query)
+
.all(...params);
+
}
+
+
/**
+
* Get total enrolled users count for a class
+
*/
+
export function getEnrolledUserCount(classId: string): number {
+
const result = db
+
.query<{ count: number }, [string]>(
+
"SELECT COUNT(*) as count FROM class_members WHERE class_id = ?",
+
)
+
.get(classId);
+
+
return result?.count || 0;
+
}
+
+
/**
+
* Check if recording should be auto-submitted
+
* Returns winning recording ID if ready, null otherwise
+
*/
+
export function checkAutoSubmit(
+
classId: string,
+
meetingTimeId: string,
+
sectionId?: string | null,
+
): string | null {
+
const recordings = getPendingRecordings(classId, meetingTimeId, sectionId);
+
+
if (recordings.length === 0) {
+
return null;
+
}
+
+
const totalUsers = getEnrolledUserCount(classId);
+
const now = Date.now() / 1000; // Current time in seconds
+
+
// Get the recording with most votes
+
const topRecording = recordings[0];
+
if (!topRecording) return null;
+
+
const uploadedAt = topRecording.created_at;
+
const timeSinceUpload = now - uploadedAt;
+
+
// Auto-submit if:
+
// 1. 30 minutes have passed since first upload, OR
+
// 2. 40% of enrolled users have voted for the top recording
+
const thirtyMinutes = 30 * 60; // 30 minutes in seconds
+
const voteThreshold = Math.ceil(totalUsers * 0.4);
+
+
if (timeSinceUpload >= thirtyMinutes) {
+
console.log(
+
`[Voting] Auto-submitting ${topRecording.id} - 30 minutes elapsed`,
+
);
+
return topRecording.id;
+
}
+
+
if (topRecording.vote_count >= voteThreshold) {
+
console.log(
+
`[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`,
+
);
+
return topRecording.id;
+
}
+
+
return null;
+
}
+
+
/**
+
* Mark a recording as auto-submitted and start transcription
+
*/
+
export function markAsAutoSubmitted(transcriptionId: string): void {
+
db.run(
+
"UPDATE transcriptions SET auto_submitted = 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
}
+
+
/**
+
* Delete a pending recording (only allowed by uploader or admin)
+
*/
+
export function deletePendingRecording(
+
transcriptionId: string,
+
userId: number,
+
isAdmin: boolean,
+
): boolean {
+
// Check ownership if not admin
+
if (!isAdmin) {
+
const recording = db
+
.query<{ user_id: number; status: string }, [string]>(
+
"SELECT user_id, status FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
+
if (!recording || recording.user_id !== userId) {
+
return false;
+
}
+
+
// Only allow deleting pending recordings
+
if (recording.status !== "pending") {
+
return false;
+
}
+
}
+
+
// Delete the recording (cascades to votes)
+
db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
+
+
return true;
+
}
+10
src/lib/vtt-cleaner.test.ts
···
test("cleanVTT preserves empty VTT", async () => {
const emptyVTT = "WEBVTT\n\n";
const result = await cleanVTT("test-empty", emptyVTT);
expect(result).toBe(emptyVTT);
});
// AI integration test - skip by default to avoid burning credits
···
test("cleanVTT preserves empty VTT", async () => {
const emptyVTT = "WEBVTT\n\n";
+
+
// Save and remove API key to avoid burning tokens
+
const originalKey = process.env.LLM_API_KEY;
+
delete process.env.LLM_API_KEY;
+
const result = await cleanVTT("test-empty", emptyVTT);
expect(result).toBe(emptyVTT);
+
+
// Restore original key
+
if (originalKey) {
+
process.env.LLM_API_KEY = originalKey;
+
}
});
// AI integration test - skip by default to avoid burning credits
+8 -8
src/lib/vtt-cleaner.ts
···
`[VTTCleaner] Processing ${segments.length} segments for ${transcriptionId}`,
);
-
const apiKey = process.env.LLM_API_KEY;
-
const apiBaseUrl = process.env.LLM_API_BASE_URL;
-
const model = process.env.LLM_MODEL;
-
-
if (!apiKey || !apiBaseUrl || !model) {
-
console.warn(
-
"[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT",
-
);
return vttContent;
}
try {
// Build the input segments
···
`[VTTCleaner] Processing ${segments.length} segments for ${transcriptionId}`,
);
+
// Check if API key is available, return original if not
+
if (!process.env.LLM_API_KEY) {
+
console.warn("[VTTCleaner] LLM_API_KEY not set, returning original VTT");
return vttContent;
}
+
+
// Validated at startup
+
const apiKey = process.env.LLM_API_KEY as string;
+
const apiBaseUrl = process.env.LLM_API_BASE_URL as string;
+
const model = process.env.LLM_MODEL as string;
try {
// Build the input segments
+4 -254
src/pages/admin.html
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
main {
-
max-width: 80rem;
-
margin: 0 auto;
-
padding: 2rem;
-
}
-
-
h1 {
-
margin-bottom: 2rem;
-
color: var(--text);
-
}
-
-
.section {
-
margin-bottom: 3rem;
-
}
-
-
.section-title {
-
font-size: 1.5rem;
-
font-weight: 600;
-
color: var(--text);
-
margin-bottom: 1rem;
-
display: flex;
-
align-items: center;
-
gap: 0.5rem;
-
}
-
-
.tabs {
-
display: flex;
-
gap: 1rem;
-
border-bottom: 2px solid var(--secondary);
-
margin-bottom: 2rem;
-
}
-
-
.tab {
-
padding: 0.75rem 1.5rem;
-
border: none;
-
background: transparent;
-
color: var(--text);
-
cursor: pointer;
-
font-size: 1rem;
-
font-weight: 500;
-
font-family: inherit;
-
border-bottom: 2px solid transparent;
-
margin-bottom: -2px;
-
transition: all 0.2s;
-
}
-
-
.tab:hover {
-
color: var(--primary);
-
}
-
-
.tab.active {
-
color: var(--primary);
-
border-bottom-color: var(--primary);
-
}
-
-
.tab-content {
-
display: none;
-
}
-
-
.tab-content.active {
-
display: block;
-
}
-
-
.empty-state {
-
text-align: center;
-
padding: 3rem;
-
color: var(--text);
-
opacity: 0.6;
-
}
-
-
.loading {
-
text-align: center;
-
padding: 3rem;
-
color: var(--text);
-
}
-
-
.error {
-
background: #fee2e2;
-
color: #991b1b;
-
padding: 1rem;
-
border-radius: 6px;
-
margin-bottom: 1rem;
-
}
-
-
.stats {
-
display: grid;
-
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
-
gap: 1rem;
-
margin-bottom: 2rem;
-
}
-
-
.stat-card {
-
background: var(--background);
-
border: 2px solid var(--secondary);
-
border-radius: 8px;
-
padding: 1.5rem;
-
}
-
-
.stat-value {
-
font-size: 2rem;
-
font-weight: 700;
-
color: var(--primary);
-
margin-bottom: 0.25rem;
-
}
-
-
.stat-label {
-
color: var(--text);
-
opacity: 0.7;
-
font-size: 0.875rem;
-
}
-
-
.timestamp {
-
color: var(--text);
-
opacity: 0.6;
-
font-size: 0.875rem;
-
}
-
</style>
</head>
<body>
···
<main>
<h1>Admin Dashboard</h1>
-
<div id="error-message" class="error" style="display: none;"></div>
<div id="loading" class="loading">Loading...</div>
-
<div id="content" style="display: none;">
<div class="stats">
<div class="stat-card">
<div class="stat-value" id="total-users">0</div>
···
<script type="module" src="../components/admin-classes.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
-
<script type="module">
-
const transcriptionsComponent = document.getElementById('transcriptions-component');
-
const usersComponent = document.getElementById('users-component');
-
const userModal = document.getElementById('user-modal');
-
const transcriptModal = document.getElementById('transcript-modal');
-
const errorMessage = document.getElementById('error-message');
-
const loading = document.getElementById('loading');
-
const content = document.getElementById('content');
-
-
// Modal functions
-
function openUserModal(userId) {
-
userModal.setAttribute('open', '');
-
userModal.userId = userId;
-
}
-
-
function closeUserModal() {
-
userModal.removeAttribute('open');
-
userModal.userId = null;
-
}
-
-
function openTranscriptModal(transcriptId) {
-
transcriptModal.setAttribute('open', '');
-
transcriptModal.transcriptId = transcriptId;
-
}
-
-
function closeTranscriptModal() {
-
transcriptModal.removeAttribute('open');
-
transcriptModal.transcriptId = null;
-
}
-
-
// Listen for component events
-
transcriptionsComponent.addEventListener('open-transcription', (e) => {
-
openTranscriptModal(e.detail.id);
-
});
-
-
usersComponent.addEventListener('open-user', (e) => {
-
openUserModal(e.detail.id);
-
});
-
-
// Listen for modal close events
-
userModal.addEventListener('close', closeUserModal);
-
userModal.addEventListener('user-updated', async () => {
-
await loadStats();
-
});
-
userModal.addEventListener('click', (e) => {
-
if (e.target === userModal) closeUserModal();
-
});
-
-
transcriptModal.addEventListener('close', closeTranscriptModal);
-
transcriptModal.addEventListener('transcript-deleted', async () => {
-
await loadStats();
-
});
-
transcriptModal.addEventListener('click', (e) => {
-
if (e.target === transcriptModal) closeTranscriptModal();
-
});
-
-
async function loadStats() {
-
try {
-
const [transcriptionsRes, usersRes] = await Promise.all([
-
fetch('/api/admin/transcriptions'),
-
fetch('/api/admin/users')
-
]);
-
-
if (!transcriptionsRes.ok || !usersRes.ok) {
-
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
-
window.location.href = '/';
-
return;
-
}
-
throw new Error('Failed to load admin data');
-
}
-
-
const transcriptions = await transcriptionsRes.json();
-
const users = await usersRes.json();
-
-
document.getElementById('total-users').textContent = users.length;
-
document.getElementById('total-transcriptions').textContent = transcriptions.length;
-
-
const failed = transcriptions.filter(t => t.status === 'failed');
-
document.getElementById('failed-transcriptions').textContent = failed.length;
-
-
loading.style.display = 'none';
-
content.style.display = 'block';
-
} catch (error) {
-
errorMessage.textContent = error.message;
-
errorMessage.style.display = 'block';
-
loading.style.display = 'none';
-
}
-
}
-
-
// Tab switching
-
function switchTab(tabName) {
-
document.querySelectorAll('.tab').forEach(t => {
-
t.classList.remove('active');
-
});
-
document.querySelectorAll('.tab-content').forEach(c => {
-
c.classList.remove('active');
-
});
-
-
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
-
const tabContent = document.getElementById(`${tabName}-tab`);
-
-
if (tabButton && tabContent) {
-
tabButton.classList.add('active');
-
tabContent.classList.add('active');
-
-
// Update URL without reloading
-
const url = new URL(window.location.href);
-
url.searchParams.set('tab', tabName);
-
// Remove subtab param when leaving classes tab
-
if (tabName !== 'classes') {
-
url.searchParams.delete('subtab');
-
}
-
window.history.pushState({}, '', url);
-
}
-
}
-
-
document.querySelectorAll('.tab').forEach(tab => {
-
tab.addEventListener('click', () => {
-
switchTab(tab.dataset.tab);
-
});
-
});
-
-
// Check for tab query parameter on load
-
const params = new URLSearchParams(window.location.search);
-
const initialTab = params.get('tab');
-
const validTabs = ['pending', 'transcriptions', 'users', 'classes'];
-
-
if (initialTab && validTabs.includes(initialTab)) {
-
switchTab(initialTab);
-
}
-
-
// Initialize
-
loadStats();
-
</script>
</body>
</html>
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<link rel="stylesheet" href="../styles/admin.css">
</head>
<body>
···
<main>
<h1>Admin Dashboard</h1>
+
<div id="error-message" class="error hidden"></div>
<div id="loading" class="loading">Loading...</div>
+
<div id="content" class="hidden">
<div class="stats">
<div class="stat-card">
<div class="stat-value" id="total-users">0</div>
···
<script type="module" src="../components/admin-classes.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
+
<script type="module" src="./admin.ts"></script>
</body>
</html>
+151
src/pages/admin.ts
···
···
+
const transcriptionsComponent = document.getElementById(
+
"transcriptions-component",
+
) as HTMLElement | null;
+
const usersComponent = document.getElementById(
+
"users-component",
+
) as HTMLElement | null;
+
const userModal = document.getElementById("user-modal") as HTMLElement | null;
+
const transcriptModal = document.getElementById(
+
"transcript-modal",
+
) as HTMLElement | null;
+
const errorMessage = document.getElementById("error-message") as HTMLElement;
+
const loading = document.getElementById("loading") as HTMLElement;
+
const content = document.getElementById("content") as HTMLElement;
+
+
// Modal functions
+
function openUserModal(userId: string) {
+
userModal.setAttribute("open", "");
+
userModal.userId = userId;
+
}
+
+
function closeUserModal() {
+
userModal.removeAttribute("open");
+
userModal.userId = null;
+
}
+
+
function openTranscriptModal(transcriptId: string) {
+
transcriptModal.setAttribute("open", "");
+
transcriptModal.transcriptId = transcriptId;
+
}
+
+
function closeTranscriptModal() {
+
transcriptModal.removeAttribute("open");
+
transcriptModal.transcriptId = null;
+
}
+
+
// Listen for component events
+
transcriptionsComponent?.addEventListener(
+
"open-transcription",
+
(e: CustomEvent) => {
+
openTranscriptModal(e.detail.id);
+
},
+
);
+
+
usersComponent?.addEventListener("open-user", (e: CustomEvent) => {
+
openUserModal(e.detail.id);
+
});
+
+
// Listen for modal close events
+
userModal?.addEventListener("close", closeUserModal);
+
userModal?.addEventListener("user-updated", async () => {
+
await loadStats();
+
});
+
userModal?.addEventListener("click", (e: MouseEvent) => {
+
if (e.target === userModal) closeUserModal();
+
});
+
+
transcriptModal?.addEventListener("close", closeTranscriptModal);
+
transcriptModal?.addEventListener("transcript-deleted", async () => {
+
await loadStats();
+
});
+
transcriptModal?.addEventListener("click", (e: MouseEvent) => {
+
if (e.target === transcriptModal) closeTranscriptModal();
+
});
+
+
async function loadStats() {
+
try {
+
const [transcriptionsRes, usersRes] = await Promise.all([
+
fetch("/api/admin/transcriptions"),
+
fetch("/api/admin/users"),
+
]);
+
+
if (!transcriptionsRes.ok || !usersRes.ok) {
+
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
+
window.location.href = "/";
+
return;
+
}
+
throw new Error("Failed to load admin data");
+
}
+
+
const transcriptions = await transcriptionsRes.json();
+
const users = await usersRes.json();
+
+
const totalUsers = document.getElementById("total-users");
+
const totalTranscriptions = document.getElementById("total-transcriptions");
+
const failedTranscriptions = document.getElementById(
+
"failed-transcriptions",
+
);
+
+
if (totalUsers) totalUsers.textContent = users.length.toString();
+
if (totalTranscriptions)
+
totalTranscriptions.textContent = transcriptions.length.toString();
+
+
const failed = transcriptions.filter(
+
(t: { status: string }) => t.status === "failed",
+
);
+
if (failedTranscriptions)
+
failedTranscriptions.textContent = failed.length.toString();
+
+
loading.classList.add("hidden");
+
content.classList.remove("hidden");
+
} catch (error) {
+
errorMessage.textContent = (error as Error).message;
+
errorMessage.classList.remove("hidden");
+
loading.classList.add("hidden");
+
}
+
}
+
+
// Tab switching
+
function switchTab(tabName: string) {
+
document.querySelectorAll(".tab").forEach((t) => {
+
t.classList.remove("active");
+
});
+
document.querySelectorAll(".tab-content").forEach((c) => {
+
c.classList.remove("active");
+
});
+
+
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
+
const tabContent = document.getElementById(`${tabName}-tab`);
+
+
if (tabButton && tabContent) {
+
tabButton.classList.add("active");
+
tabContent.classList.add("active");
+
+
// Update URL without reloading
+
const url = new URL(window.location.href);
+
url.searchParams.set("tab", tabName);
+
// Remove subtab param when leaving classes tab
+
if (tabName !== "classes") {
+
url.searchParams.delete("subtab");
+
}
+
window.history.pushState({}, "", url);
+
}
+
}
+
+
document.querySelectorAll(".tab").forEach((tab) => {
+
tab.addEventListener("click", () => {
+
switchTab((tab as HTMLElement).dataset.tab || "");
+
});
+
});
+
+
// Check for tab query parameter on load
+
const params = new URLSearchParams(window.location.search);
+
const initialTab = params.get("tab");
+
const validTabs = ["pending", "transcriptions", "users", "classes"];
+
+
if (initialTab && validTabs.includes(initialTab)) {
+
switchTab(initialTab);
+
}
+
+
// Initialize
+
loadStats();
+2 -85
src/pages/index.html
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
.hero-title {
-
font-size: 3rem;
-
font-weight: 700;
-
color: var(--text);
-
margin-bottom: 1rem;
-
}
-
-
.hero-subtitle {
-
font-size: 1.25rem;
-
color: var(--text);
-
opacity: 0.8;
-
margin-bottom: 2rem;
-
}
-
-
main {
-
text-align: center;
-
padding: 4rem 2rem;
-
}
-
-
.cta-buttons {
-
display: flex;
-
gap: 1rem;
-
justify-content: center;
-
margin-top: 2rem;
-
}
-
-
.btn {
-
padding: 0.75rem 1.5rem;
-
border-radius: 6px;
-
font-size: 1rem;
-
font-weight: 500;
-
cursor: pointer;
-
transition: all 0.2s;
-
font-family: inherit;
-
border: 2px solid;
-
text-decoration: none;
-
display: inline-block;
-
}
-
-
.btn-primary {
-
background: var(--primary);
-
color: white;
-
border-color: var(--primary);
-
}
-
-
.btn-primary:hover {
-
background: transparent;
-
color: var(--primary);
-
}
-
-
.btn-secondary {
-
background: transparent;
-
color: var(--text);
-
border-color: var(--secondary);
-
}
-
-
.btn-secondary:hover {
-
border-color: var(--primary);
-
color: var(--primary);
-
}
-
-
@media (max-width: 640px) {
-
.hero-title {
-
font-size: 2.5rem;
-
}
-
-
.cta-buttons {
-
flex-direction: column;
-
align-items: center;
-
}
-
}
-
</style>
</head>
<body>
···
</main>
<script type="module" src="../components/auth.ts"></script>
-
<script type="module">
-
document.getElementById('start-btn').addEventListener('click', async () => {
-
const authComponent = document.querySelector('auth-component');
-
const isLoggedIn = await authComponent.isAuthenticated();
-
-
if (isLoggedIn) {
-
window.location.href = '/classes';
-
} else {
-
authComponent.openAuthModal();
-
}
-
});
-
</script>
</body>
</html>
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<link rel="stylesheet" href="../styles/index.css">
</head>
<body>
···
</main>
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="./index.ts"></script>
</body>
</html>
+14
src/pages/index.ts
···
···
+
document.getElementById("start-btn")?.addEventListener("click", async () => {
+
const authComponent = document.querySelector("auth-component");
+
if (!authComponent) return;
+
+
const isLoggedIn = await (
+
authComponent as { isAuthenticated: () => Promise<boolean> }
+
).isAuthenticated();
+
+
if (isLoggedIn) {
+
window.location.href = "/classes";
+
} else {
+
(authComponent as { openAuthModal: () => void }).openAuthModal();
+
}
+
});
+18 -129
src/pages/reset-password.html
···
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Thistle</title>
-
<link rel="icon"
-
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
</head>
<body>
-
<auth-component></auth-component>
-
-
<main>
-
<div class="container" style="max-width: 28rem; margin: 4rem auto; padding: 0 1rem;">
-
<div class="reset-card" style="background: var(--white); border: 1px solid var(--silver); border-radius: 0.5rem; padding: 2rem;">
-
<h1 style="margin-top: 0; text-align: center;">🪻 Reset Password</h1>
-
-
<div id="form-container">
-
<form id="reset-form">
-
<div style="margin-bottom: 1.5rem;">
-
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">New Password</label>
-
<input
-
type="password"
-
id="password"
-
required
-
minlength="8"
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
-
>
-
</div>
-
-
<div style="margin-bottom: 1.5rem;">
-
<label for="confirm-password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Confirm Password</label>
-
<input
-
type="password"
-
id="confirm-password"
-
required
-
minlength="8"
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
-
>
-
</div>
-
-
<div id="error-message" style="display: none; color: var(--coral); margin-bottom: 1rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.25rem;"></div>
-
-
<button
-
type="submit"
-
id="submit-btn"
-
style="width: 100%; padding: 0.75rem; background: var(--accent); color: var(--white); border: none; border-radius: 0.25rem; font-size: 1rem; font-weight: 500; cursor: pointer;"
-
>
-
Reset Password
-
</button>
-
</form>
-
-
<div style="text-align: center; margin-top: 1.5rem;">
-
<a href="/" style="color: var(--primary); text-decoration: none;">Back to home</a>
-
</div>
-
</div>
-
-
<div id="success-message" style="display: none; text-align: center;">
-
<div style="color: var(--primary); margin-bottom: 1rem;">
-
✓ Password reset successfully!
-
</div>
-
<a href="/" style="color: var(--accent); font-weight: 500; text-decoration: none;">Go to home</a>
-
</div>
-
</div>
</div>
</main>
<script type="module" src="../components/auth.ts"></script>
-
<script type="module">
-
import { hashPasswordClient } from '../lib/client-auth.ts';
-
-
const form = document.getElementById('reset-form');
-
const passwordInput = document.getElementById('password');
-
const confirmPasswordInput = document.getElementById('confirm-password');
-
const submitBtn = document.getElementById('submit-btn');
-
const errorMessage = document.getElementById('error-message');
-
const formContainer = document.getElementById('form-container');
-
const successMessage = document.getElementById('success-message');
-
-
// Get token from URL
-
const urlParams = new URLSearchParams(window.location.search);
-
const token = urlParams.get('token');
-
-
if (!token) {
-
errorMessage.textContent = 'Invalid or missing reset token';
-
errorMessage.style.display = 'block';
-
form.style.display = 'none';
-
}
-
-
form?.addEventListener('submit', async (e) => {
-
e.preventDefault();
-
-
const password = passwordInput.value;
-
const confirmPassword = confirmPasswordInput.value;
-
-
// Validate passwords match
-
if (password !== confirmPassword) {
-
errorMessage.textContent = 'Passwords do not match';
-
errorMessage.style.display = 'block';
-
return;
-
}
-
-
// Validate password length
-
if (password.length < 8) {
-
errorMessage.textContent = 'Password must be at least 8 characters';
-
errorMessage.style.display = 'block';
-
return;
-
}
-
-
errorMessage.style.display = 'none';
-
submitBtn.disabled = true;
-
submitBtn.textContent = 'Resetting...';
-
-
try {
-
// Hash password client-side
-
const hashedPassword = await hashPasswordClient(password);
-
-
const response = await fetch('/api/auth/reset-password', {
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ token, password: hashedPassword }),
-
});
-
-
const data = await response.json();
-
-
if (!response.ok) {
-
throw new Error(data.error || 'Failed to reset password');
-
}
-
-
// Show success message
-
formContainer.style.display = 'none';
-
successMessage.style.display = 'block';
-
-
} catch (error) {
-
errorMessage.textContent = error.message || 'Failed to reset password';
-
errorMessage.style.display = 'block';
-
submitBtn.disabled = false;
-
submitBtn.textContent = 'Reset Password';
-
}
-
});
-
</script>
</body>
</html>
···
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Thistle</title>
+
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
+
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
+
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<link rel="stylesheet" href="../styles/reset-password.css">
</head>
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo">
+
<span>Thistle</span>
+
</a>
+
<auth-component></auth-component>
</div>
+
</header>
+
+
<main>
+
<reset-password-form id="reset-form"></reset-password-form>
</main>
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/reset-password-form.ts"></script>
+
<script type="module" src="./reset-password.ts"></script>
</body>
</html>
+10
src/pages/reset-password.ts
···
···
+
// Wait for component to be defined before setting token
+
await customElements.whenDefined("reset-password-form");
+
+
// Get token from URL and pass to component
+
const urlParams = new URLSearchParams(window.location.search);
+
const token = urlParams.get("token");
+
const resetForm = document.getElementById("reset-form");
+
if (resetForm) {
+
(resetForm as { token: string | null }).token = token;
+
}
+1 -6
src/pages/settings.html
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
-
<style>
-
main {
-
max-width: 64rem;
-
}
-
</style>
</head>
<body>
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<link rel="stylesheet" href="../styles/settings.css">
</head>
<body>
+3 -21
src/pages/transcribe.html
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
.page-header {
-
text-align: center;
-
margin-bottom: 3rem;
-
}
-
-
.page-title {
-
font-size: 2.5rem;
-
font-weight: 700;
-
color: var(--text);
-
margin-bottom: 0.5rem;
-
}
-
-
.page-subtitle {
-
font-size: 1.125rem;
-
color: var(--text);
-
opacity: 0.8;
-
}
-
</style>
</head>
<body>
···
</header>
<main>
-
<div style="margin-bottom: 1rem;">
-
<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
← Back to classes
</a>
</div>
···
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<link rel="stylesheet" href="../styles/transcribe.css">
</head>
<body>
···
</header>
<main>
+
<div class="mb-1">
+
<a href="/classes" class="back-link">
← Back to classes
</a>
</div>
+120
src/styles/admin.css
···
···
+
main {
+
max-width: 80rem;
+
margin: 0 auto;
+
padding: 2rem;
+
}
+
+
h1 {
+
margin-bottom: 2rem;
+
color: var(--text);
+
}
+
+
.section {
+
margin-bottom: 3rem;
+
}
+
+
.section-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.tabs {
+
display: flex;
+
gap: 1rem;
+
border-bottom: 2px solid var(--secondary);
+
margin-bottom: 2rem;
+
}
+
+
.tab {
+
padding: 0.75rem 1.5rem;
+
border: none;
+
background: transparent;
+
color: var(--text);
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
border-bottom: 2px solid transparent;
+
margin-bottom: -2px;
+
transition: all 0.2s;
+
}
+
+
.tab:hover {
+
color: var(--primary);
+
}
+
+
.tab.active {
+
color: var(--primary);
+
border-bottom-color: var(--primary);
+
}
+
+
.tab-content {
+
display: none;
+
}
+
+
.tab-content.active {
+
display: block;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
+
.loading {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
}
+
+
.error {
+
background: #fee2e2;
+
color: #991b1b;
+
padding: 1rem;
+
border-radius: 6px;
+
margin-bottom: 1rem;
+
}
+
+
.stats {
+
display: grid;
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+
gap: 1rem;
+
margin-bottom: 2rem;
+
}
+
+
.stat-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.5rem;
+
}
+
+
.stat-value {
+
font-size: 2rem;
+
font-weight: 700;
+
color: var(--primary);
+
margin-bottom: 0.25rem;
+
}
+
+
.stat-label {
+
color: var(--text);
+
opacity: 0.7;
+
font-size: 0.875rem;
+
}
+
+
.timestamp {
+
color: var(--text);
+
opacity: 0.6;
+
font-size: 0.875rem;
+
}
+
+
.hidden {
+
display: none;
+
}
+71
src/styles/index.css
···
···
+
.hero-title {
+
font-size: 3rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
+
.hero-subtitle {
+
font-size: 1.25rem;
+
color: var(--text);
+
opacity: 0.8;
+
margin-bottom: 2rem;
+
}
+
+
main {
+
text-align: center;
+
padding: 4rem 2rem;
+
}
+
+
.cta-buttons {
+
display: flex;
+
gap: 1rem;
+
justify-content: center;
+
margin-top: 2rem;
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid;
+
text-decoration: none;
+
display: inline-block;
+
}
+
+
.btn-primary {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-primary:hover {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-secondary {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-secondary:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
@media (max-width: 640px) {
+
.hero-title {
+
font-size: 2.5rem;
+
}
+
+
.cta-buttons {
+
flex-direction: column;
+
align-items: center;
+
}
+
}
+6
src/styles/reset-password.css
···
···
+
main {
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
padding: 4rem 1rem;
+
}
+3
src/styles/settings.css
···
···
+
main {
+
max-width: 64rem;
+
}
+27
src/styles/transcribe.css
···
···
+
.page-header {
+
text-align: center;
+
margin-bottom: 3rem;
+
}
+
+
.page-title {
+
font-size: 2.5rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.page-subtitle {
+
font-size: 1.125rem;
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.back-link {
+
color: var(--paynes-gray);
+
text-decoration: none;
+
font-size: 0.875rem;
+
}
+
+
.mb-1 {
+
margin-bottom: 1rem;
+
}