🪻 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)
+
# 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
+
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
+
# Origin - full URL of your app (RECOMMENDED - used for email links)
# Must match exactly where users access your app
-
# ORIGIN=https://thistle.app
+
# In production, set this to your public URL
+
ORIGIN=http://localhost:3000
-
# Polar.sh payment stuff
+
# 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=XXX
+
POLAR_ACCESS_TOKEN=paste_your_polar_token_here
# Get product ID from your Polar dashboard (create a product first)
-
POLAR_PRODUCT_ID=3f1ab9f9-d573-49d4-ac0a-a78bfb06c347
+
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=whsec_xxxxxxxxxxxxx
-
-
# Environment (set to 'production' in production)
-
NODE_ENV=development
+
POLAR_WEBHOOK_SECRET=paste_your_webhook_secret_here
-
# Email Configuration (MailChannels)
+
# 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-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
-
DKIM_DOMAIN=thistle.app
-
SMTP_FROM_EMAIL=noreply@thistle.app
+
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
+107
CRUSH.md
···
**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
As the codebase grows, document:
+10
LICENSE.md
···
+
# The O'Saasy License
+
+
Copyright © `2025` `Kieran Klukas`
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
+
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -1
README.md
···
```
Or manually:
+
```bash
cd whisper-server
pip install -r requirements.txt
···
</p>
<p align="center">
-
<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
+
<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=O'Saasy&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
</p>
-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,
+
verifyEmailTemplate,
} from "../src/lib/email-templates";
const targetEmail = process.argv[2];
+223 -21
src/components/admin-classes.ts
···
@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();
-
this.classes = classesData.classes || [];
+
// 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);
+
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
+
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" : ""}">
+
<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>
···
${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 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");
-
}
+
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.";
+
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.";
+
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.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();
+
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.";
+
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.";
+
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();
+
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.";
+
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";
+
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.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.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete user. Please try again.";
}
}
-
private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {
+
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 };
+
this.deleteState = {
+
id: userId,
+
type: "revoke",
+
clicks: newClicks,
+
timeout,
+
};
}
-
private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) {
+
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.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);
+
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="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>`
-
: ""
-
}
+
${
+
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>`
-
}
+
${
+
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`
+
${
+
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.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);
+
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')}`;
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
override disconnectedCallback() {
+60 -11
src/components/class-registration-modal.ts
···
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) {
+
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 }),
+
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 {
+
} catch (error) {
+
console.error("Failed to join class:", error);
this.error = "Failed to join class. Please try again.";
-
} finally {
this.isJoining = false;
+
this.requestUpdate();
}
}
···
<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-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();
-
this.handleJoin(cls.id);
+
console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections);
+
this.handleJoin(cls.id, cls.sections);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
-
</button>
+
</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";
+
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() {
-
if (!this.searchQuery) return this.transcriptions;
+
let filtered = this.transcriptions;
-
const query = this.searchQuery.toLowerCase();
-
return this.transcriptions.filter((t) =>
-
t.original_filename.toLowerCase().includes(query),
-
);
+
// 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}</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`
+
${
+
!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`
+
`
+
: 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`
+
: html`
${this.filteredTranscriptions.map(
-
(t) => html`
+
(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>
+1
src/components/classes-overview.ts
···
}
private async handleClassJoined() {
+
this.showRegistrationModal = false;
await this.loadClasses();
}
+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>
+
`;
+
}
+
}
+18 -7
src/components/reset-password-form.ts
···
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) {
+
if (
+
changedProperties.has("token") &&
+
this.token &&
+
!this.email &&
+
!this.isLoadingEmail
+
) {
await this.loadEmail();
}
}
···
this.email = data.email;
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to verify reset token";
+
this.error =
+
err instanceof Error ? err.message : "Failed to verify reset token";
} finally {
this.isLoadingEmail = false;
}
···
<h1 class="reset-title">Reset Password</h1>
<form @submit=${this.handleSubmit}>
-
${this.error
-
? html`<div class="error-banner">${this.error}</div>`
-
: ""}
+
${
+
this.error
+
? html`<div class="error-banner">${this.error}</div>`
+
: ""
+
}
<div class="form-group">
<label for="password">New Password</label>
···
}
// Hash password client-side with user's email
-
const hashedPassword = await hashPasswordClient(this.password, this.email);
+
const hashedPassword = await hashPasswordClient(
+
this.password,
+
this.email,
+
);
const response = await fetch("/api/auth/reset-password", {
method: "POST",
+10 -5
src/components/transcription.ts
···
async checkHealth() {
try {
-
const response = await fetch("/api/transcriptions/health");
+
const response = await fetch("/api/health");
if (response.ok) {
const data = await response.json();
-
this.serviceAvailable = data.available;
+
this.serviceAvailable = data.status === "healthy";
} else {
this.serviceAvailable = false;
}
···
}
override render() {
-
const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin);
+
const canUpload =
+
this.serviceAvailable && (this.hasSubscription || this.isAdmin);
return html`
-
${!this.hasSubscription && !this.isAdmin ? 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;
}
+
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 handleFileSelect(e: Event) {
+
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 handleMeetingTimeChange(e: Event) {
-
const select = e.target as HTMLSelectElement;
-
this.selectedMeetingTimeId = select.value || null;
-
}
+
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();
-
private handleClose() {
-
if (this.uploading) return;
-
this.open = false;
-
this.selectedFile = null;
-
this.selectedMeetingTimeId = null;
-
this.error = null;
-
this.dispatchEvent(new CustomEvent("close"));
-
}
+
// 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";
+
}
+
});
-
private async handleUpload() {
-
if (!this.selectedFile) {
-
this.error = "Please select a file to upload";
-
return;
-
}
+
// Handle errors
+
xhr.addEventListener("error", () => {
+
this.uploading = false;
+
this.error = "Upload failed. Please try again.";
+
});
-
if (!this.selectedMeetingTimeId) {
-
this.error = "Please select a meeting time";
-
return;
+
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.";
}
+
}
-
this.uploading = true;
-
this.error = null;
+
private async detectMeetingTime() {
+
if (!this.classId) return;
+
+
this.detectingMeetingTime = true;
try {
const formData = new FormData();
-
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
formData.append("meeting_time_id", this.selectedMeetingTimeId);
+
+
// 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;
+
}
-
const response = await fetch("/api/transcriptions", {
+
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();
-
throw new Error(data.error || "Upload failed");
+
this.error = data.error || "Failed to update meeting time";
+
this.submitting = false;
+
return;
}
-
// Success - close modal and notify parent
+
// Success - close modal and refresh
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;
+
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>
-
<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>
+
${
+
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}>
+
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
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>
+
${
+
this.uploadComplete && this.selectedMeetingTimeId
+
? html`
+
<button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}>
+
${this.submitting ? "Submitting..." : "Confirm & Submit"}
+
</button>
+
`
+
: ""
+
}
</div>
</div>
</div>
+25 -8
src/components/user-modal.ts
···
private async handleChangeEmail(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
-
const input = form.querySelector("input") as HTMLInputElement;
+
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 }),
+
body: JSON.stringify({ email, skipVerification }),
});
if (!res.ok) {
···
throw new Error(data.error || "Failed to update email");
}
-
alert("Email updated successfully");
+
const data = await res.json();
+
alert(data.message || "Email updated successfully");
await this.loadUserDetails();
this.dispatchEvent(
new CustomEvent("user-updated", { bubbles: true, composed: true }),
···
submitBtn.textContent = "Sending...";
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
+
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();
···
"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";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to send password reset email";
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Send Reset Email";
···
<div class="form-group">
<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>
+153 -46
src/components/user-settings.ts
···
canceled_at: number | null;
}
-
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";
+
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);
+
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 =
+
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) {
-
const data = await response.json();
this.error = data.error || "Failed to update email";
return;
}
-
// Reload user data
-
await this.loadUser();
+
// 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`
+
${
+
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
+
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}
-
Save
+
${this.updatingEmail ? html`<span class="spinner"></span>` : "Save"}
</button>
<button
class="btn btn-neutral btn-small"
···
</button>
</div>
-
: html`
+
: 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`
+
${
+
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"
-
);
+
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)";
+
const statusColor =
+
this.subscription.status === "canceled"
+
? "var(--accent)"
+
: "var(--secondary)";
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
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`
+
${
+
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`
+
${
+
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`
+
${
+
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`
+
${
+
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`
+
${
+
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`
+
${
+
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`
+
${
+
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,
+
email_notifications_enabled:
+
this.emailNotificationsEnabled,
}),
});
-
+
if (!response.ok) {
const data = await response.json();
-
throw new Error(data.error || "Failed to update notification settings");
+
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";
+
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");
+
// 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: "Complete schema with class system",
+
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);
-
`,
-
},
-
{
-
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 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,
-
section TEXT,
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);
-
`,
-
},
-
{
-
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);
+
+
-- 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 email notification preferences",
+
name: "Add recording_date to transcriptions for chronological ordering",
sql: `
-
ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1;
-
`,
+
-- 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);
+
`,
},
];
+3 -1
src/index.test.README.md
···
- `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/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
+353 -747
src/index.test.ts
···
expect,
test,
} from "bun:test";
-
import db from "./db/schema";
+
import type { Subprocess } from "bun";
import { hashPasswordClient } from "./lib/client-auth";
-
// Test server URL - uses port 3001 for testing to avoid conflicts
+
// Test server configuration
const TEST_PORT = 3001;
const BASE_URL = `http://localhost:${TEST_PORT}`;
+
const TEST_DB_PATH = "./thistle.test.db";
-
// Check if server is available
-
let serverAvailable = false;
+
// Test server process
+
let serverProcess: Subprocess | null = null;
beforeAll(async () => {
+
// Clean up any existing test database
try {
-
const response = await fetch(`${BASE_URL}/api/transcriptions/health`, {
-
signal: AbortSignal.timeout(1000),
-
});
-
serverAvailable = response.ok || response.status === 404;
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
} 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;
+
// 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
···
});
}
-
// 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%'",
-
);
+
// 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();
-
// 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");
-
}
+
// 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,
+
}),
+
});
-
beforeEach(() => {
-
if (serverAvailable) {
-
cleanupTestData();
+
if (loginResponse.status !== 200) {
+
const error = await loginResponse.json();
+
throw new Error(`Login failed: ${JSON.stringify(error)}`);
}
-
});
-
afterAll(() => {
-
if (serverAvailable) {
-
cleanupTestData();
+
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`);
}
-
});
-
// 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();
-
});
+
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", () => {
-
serverTest("should register a new user successfully", async () => {
+
test("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
···
}),
});
-
expect(response.status).toBe(200);
+
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(extractSessionCookie(response)).toBeTruthy();
+
expect(data.email_verification_required).toBe(true);
});
-
serverTest("should reject registration with missing email", async () => {
+
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");
});
-
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
+
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: hashedPassword,
-
name: TEST_USER.name,
+
password: "short",
}),
});
expect(response.status).toBe(400);
const data = await response.json();
-
expect(data.error).toBe("Email already registered");
+
expect(data.error).toBe("Invalid password format");
});
-
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
+
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" },
···
}),
});
-
// Login
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
+
// 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(200);
+
expect(response.status).toBe(409);
const data = await response.json();
-
expect(data.user).toBeDefined();
-
expect(data.user.email).toBe(TEST_USER.email);
-
expect(extractSessionCookie(response)).toBeTruthy();
+
expect(data.error).toBe("Email already registered");
});
-
serverTest("should reject login with invalid credentials", async () => {
-
// Register user first
+
test("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
+
"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: TEST_USER.email,
+
email: "ratelimit@example.com",
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)
+
// 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 < 11; i++) {
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
+
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_USER.email,
+
email: "ratelimit@example.com",
password: hashedPassword,
}),
});
···
});
});
-
describe("POST /api/auth/logout", () => {
-
serverTest("should logout successfully", async () => {
+
describe("POST /api/auth/login", () => {
+
test("should login successfully with valid credentials", 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);
+
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", () => {
-
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);
+
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(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
-
serverTest("should require authentication", async () => {
+
test("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);
+
test("should update user email", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Update email
+
// 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);
});
-
serverTest("should reject duplicate email", async () => {
+
test("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);
+
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(400);
+
expect(response.status).toBe(409);
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);
+
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);
});
-
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);
+
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", () => {
-
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);
+
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);
-
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);
+
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", () => {
-
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);
+
test("should update user avatar", async () => {
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// 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`);
+
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("available");
-
expect(typeof data.available).toBe("boolean");
-
},
-
);
+
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", () => {
-
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);
+
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);
});
-
serverTest("should require authentication", async () => {
+
test("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);
+
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(200);
+
expect(response.status).toBe(201);
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);
+
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);
});
-
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);
+
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");
});
-
serverTest("should require authentication", async () => {
+
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 () => {
-
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);
+
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
-
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);
+
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", () => {
-
serverTest("should return all users for admin", async () => {
+
test("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 () => {
+
test("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 () => {
+
test("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 () => {
+
test("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 () => {
+
test("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 () => {
+
test("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);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
expect(verifyResponse.status).toBe(401);
});
-
serverTest("should reject non-admin users", async () => {
+
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", () => {
-
serverTest("should update user role as admin", async () => {
+
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);
-
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 () => {
+
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", () => {
-
serverTest("should return user details for admin", async () => {
+
test("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 () => {
+
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", () => {
-
serverTest("should update user name as admin", async () => {
+
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);
});
-
serverTest("should reject empty names", async () => {
+
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", () => {
-
serverTest("should update user email as admin", async () => {
+
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);
});
-
serverTest("should reject duplicate emails", async () => {
+
test("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
adminCookie,
···
},
);
-
expect(response.status).toBe(400);
+
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", () => {
-
serverTest("should return user sessions as admin", async () => {
+
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", () => {
-
serverTest("should delete all user sessions as admin", async () => {
+
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(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
+
expect(response.status).toBe(204);
// 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);
+
// Register and login
+
sessionCookie = await registerAndLogin(TEST_USER);
});
describe("GET /api/passkeys", () => {
-
serverTest("should return user passkeys", async () => {
+
test("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 () => {
+
test("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",
-
},
-
);
+
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");
-
},
-
);
+
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 () => {
+
test("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 () => {
+
test("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 () => {
+
test("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
+1567 -481
src/index.ts
···
import {
authenticateUser,
cleanupExpiredSessions,
+
consumeEmailChangeToken,
+
consumePasswordResetToken,
+
createEmailChangeToken,
+
createEmailVerificationToken,
+
createPasswordResetToken,
createSession,
createUser,
deleteAllUserSessions,
···
getUserByEmail,
getUserBySession,
getUserSessionsForUser,
+
getVerificationCodeSentAt,
+
isEmailVerified,
type UserRole,
updateUserAvatar,
updateUserEmail,
···
updateUserName,
updateUserPassword,
updateUserRole,
-
createEmailVerificationToken,
-
verifyEmailToken,
+
verifyEmailChangeToken,
verifyEmailCode,
-
isEmailVerified,
-
getVerificationCodeSentAt,
-
createPasswordResetToken,
+
verifyEmailToken,
verifyPasswordResetToken,
-
consumePasswordResetToken,
} 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 { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
+
import { clearRateLimit, enforceRateLimit } 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";
+
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 hour
-
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
+
// 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
+
// Search for customer by email (validated at startup)
const customers = await polar.customers.list({
-
organizationId: process.env.POLAR_ORGANIZATION_ID,
+
organizationId: process.env.POLAR_ORGANIZATION_ID as string,
query: email,
});
···
customerId: customer.id,
});
-
if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
+
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'
+
(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}`);
+
console.log(
+
`[Sync] No current subscriptions found for customer ${customer.id}`,
+
);
return;
}
···
}
}
-
// Sync with Whisper DB on startup
try {
await whisperService.syncWithWhisper();
···
}
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
-
setInterval(
+
const syncInterval = setInterval(
async () => {
try {
await whisperService.syncWithWhisper();
···
5 * 60 * 1000,
);
-
// Clean up stale files daily
-
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
+
// Clean up stale files hourly
+
const fileCleanupInterval = setInterval(
+
() => whisperService.cleanupStaleFiles(),
+
60 * 60 * 1000, // 1 hour
+
);
const server = Bun.serve({
-
port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
+
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 },
);
}
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ 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 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." },
+
{
+
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 },
+
{ 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: 400 },
+
{ status: 409 },
);
}
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)) {
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ 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);
+
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);
+
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",
+
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 },
-
);
+
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({ message: "Verification email sent" });
+
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 },
-
});
+
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" });
+
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({
+
return Response.json({
+
success: true,
message: "Verification code sent",
verification_code_sent_at: sentAt,
});
···
}
return Response.json({
+
success: true,
message:
"If an account exists with that email, a password reset link has been sent",
});
···
const token = url.searchParams.get("token");
if (!token) {
-
return Response.json(
-
{ error: "Token required" },
-
{ status: 400 },
-
);
+
return Response.json({ error: "Token required" }, { status: 400 });
}
const userId = verifyPasswordResetToken(token);
···
// Get user's email for client-side password hashing
const user = db
-
.query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?")
+
.query<{ email: string }, [number]>(
+
"SELECT email FROM users WHERE id = ?",
+
)
.get(userId);
if (!user) {
···
}
// Validate password format (client-side hashed PBKDF2)
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
await updateUserPassword(userId, password);
consumePasswordResetToken(token);
-
return Response.json({ message: "Password reset successfully" });
+
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) => {
-
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 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);
+
// 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);
+
// 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,
-
});
+
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 Response.json({ success: true });
+
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 Response.json({ success: true });
+
return new Response(null, { status: 204 });
} 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 });
+
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);
}
-
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 });
+
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);
-
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 });
-
}
+
try {
+
const user = requireAuth(req);
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "delete-user", {
-
ip: { max: 3, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
// 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 },
-
{
+
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) => {
-
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 user = requireAuth(req);
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-email", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
// 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")) {
+
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: 400 },
+
{ status: 409 },
);
-
return Response.json(
-
{ error: "Failed to update email" },
-
{ status: 500 },
-
);
+
+
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/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 });
-
}
+
"/api/user/email/verify": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-password", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
if (!token) {
+
return Response.redirect(
+
"/settings?tab=account&error=invalid-token",
+
302,
+
);
+
}
-
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 },
+
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 {
-
await updateUserPassword(user.id, password);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update password" },
-
{ status: 500 },
-
);
+
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) => {
-
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 },
-
);
+
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) => {
-
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 },
-
);
+
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) => {
-
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 });
-
}
+
const user = requireAuth(req);
-
try {
-
const { polar } = await import("./lib/polar");
+
const rateLimitError = enforceRateLimit(req, "update-notifications", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const productId = process.env.POLAR_PRODUCT_ID;
-
if (!productId) {
+
const body = await req.json();
+
const { email_notifications_enabled } = body;
+
if (typeof email_notifications_enabled !== "boolean") {
return Response.json(
-
{ error: "Product not configured" },
-
{ status: 500 },
+
{ error: "email_notifications_enabled must be a boolean" },
+
{ status: 400 },
);
-
-
const successUrl = process.env.POLAR_SUCCESS_URL;
-
if (!successUrl) {
+
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: "Success URL not configured" },
+
{ 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 (error) {
-
console.error("Failed to create checkout:", error);
-
return Response.json(
-
{ error: "Failed to create checkout session" },
-
{ status: 500 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
"/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 {
+
const user = requireAuth(req);
+
// 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 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
"/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 user = requireAuth(req);
-
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 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
"/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());
+
const { validateEvent } = await import("@polar-sh/sdk/webhooks");
-
// 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 },
-
);
-
}
+
// Get raw body as string
+
const rawBody = await req.text();
+
const headers = Object.fromEntries(req.headers.entries());
-
const event = validateEvent(rawBody, headers, webhookSecret);
+
// 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}`);
+
console.log(`[Webhook] Received event: ${event.type}`);
-
// Handle different event types
+
// 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) {
-
console.error("[Webhook] Error processing webhook:", error);
-
return Response.json(
-
{ error: "Webhook processing failed" },
-
{ status: 400 },
-
);
+
// 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]>(
+
.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);
+
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 },
-
);
+
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)) {
+
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/transcriptions/health": {
+
"/api/health": {
GET: async () => {
-
const isHealthy = await whisperService.checkHealth();
-
return Response.json({ available: isHealthy });
+
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);
+
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 },
-
);
+
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)) {
+
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 },
+
{ status: 409 },
);
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, 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 },
-
);
+
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)) {
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
···
},
},
-
"/api/transcriptions": {
+
"/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 = requireSubscription(req);
+
const user = requireAuth(req);
+
const classId = req.params.classId;
+
const meetingTimeId = req.params.meetingTimeId;
-
const transcriptions = db
+
// 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<
-
{
-
id: string;
-
filename: string;
-
original_filename: string;
-
class_id: string | null;
-
status: string;
-
progress: number;
-
created_at: number;
-
},
-
[number]
+
{ class_id: string; meeting_time_id: string; status: string },
+
[string]
>(
-
"SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
+
"SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
-
.all(user.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 });
+
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 meetingTimeId = formData.get("meeting_time_id") as
+
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);
-
// Create database record
+
// 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, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
transcriptionId,
user.id,
classId,
-
meetingTimeId,
+
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",
-
});
+
return Response.json(
+
{
+
id: transcriptionId,
+
message: "Upload successful",
+
},
+
{ status: 201 },
+
);
} catch (error) {
return handleError(error);
···
GET: async (req) => {
try {
requireAdmin(req);
-
const transcriptions = getAllTranscriptions();
-
return Response.json(transcriptions);
+
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 users = getAllUsersWithStats();
-
return Response.json(users);
+
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 Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
await deleteUser(userId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
.get(userId);
if (!user) {
-
return Response.json(
-
{ error: "User not found" },
-
{ status: 404 },
-
);
+
return Response.json({ error: "User not found" }, { status: 404 });
try {
···
}),
});
-
return Response.json({
+
return Response.json({
success: true,
-
message: "Password reset email sent"
+
message: "Password reset email sent",
});
} catch (error) {
console.error("[Admin] Password reset error:", error);
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
const body = await req.json();
const { name } = body as { name: string };
-
if (!name || name.trim().length === 0) {
+
const nameValidation = validateName(name);
+
if (!nameValidation.valid) {
return Response.json(
-
{ error: "Name cannot be empty" },
+
{ error: nameValidation.error },
{ status: 400 },
);
···
const body = await req.json();
-
const { email } = body as { email: string };
+
const { email, skipVerification } = body as {
+
email: string;
+
skipVerification?: boolean;
+
};
-
if (!email || !email.includes("@")) {
+
const emailValidation = validateEmail(email);
+
if (!emailValidation.valid) {
return Response.json(
-
{ error: "Invalid email address" },
+
{ error: emailValidation.error },
{ status: 400 },
);
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
+
{ status: 409 },
);
-
updateUserEmailAddress(userId, email);
-
return Response.json({ success: true });
+
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 Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
GET: async (req) => {
try {
const user = requireAuth(req);
-
const classes = getClassesForUser(user.id, user.role === "admin");
+
const url = new URL(req.url);
-
// Group by semester/year
+
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 classes) {
+
for (const cls of result.data) {
const key = `${cls.semester} ${cls.year}`;
if (!grouped[key]) {
grouped[key] = [];
···
});
-
return Response.json({ classes: grouped });
+
return Response.json({
+
classes: grouped,
+
pagination: result.pagination,
+
});
} catch (error) {
return handleError(error);
···
meeting_times,
} = body;
-
if (!course_code || !name || !professor || !semester || !year) {
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(course_code);
+
if (!courseCodeValidation.valid) {
return Response.json(
-
{ error: "Missing required fields" },
+
{ 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);
+
return Response.json(newClass, { status: 201 });
} catch (error) {
return handleError(error);
···
.all(user.id)
.map((row) => row.class_id);
-
// Add is_enrolled flag to each class
+
// 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;
-
if (!classId || typeof classId !== "string") {
+
const classIdValidation = validateClassId(classId);
+
if (!classIdValidation.valid) {
return Response.json(
-
{ error: "Class ID required" },
+
{ error: classIdValidation.error },
{ status: 400 },
);
-
const result = joinClass(classId, user.id);
+
const result = joinClass(classId, user.id, sectionId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
meetingTimes,
} = body;
-
if (!courseCode || !courseName || !professor || !semester || !year) {
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(courseCode);
+
if (!courseCodeValidation.valid) {
return Response.json(
-
{ error: "Missing required fields" },
+
{ 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 });
+
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 Response.json({ success: true });
+
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 Response.json({ success: true });
+
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 Response.json({ success: true });
+
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 Response.json({ success: true });
+
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);
+
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 Response.json({ success: true });
+
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 Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
requireAdmin(req);
const transcriptId = req.params.id;
-
// Update status to 'selected' and start transcription
-
db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
-
"selected",
-
transcriptId,
-
]);
-
-
// Get filename to start transcription
+
// Check if transcription exists and get its current status
const transcription = db
-
.query<{ filename: string }, [string]>(
-
"SELECT filename FROM transcriptions WHERE id = ?",
+
.query<{ filename: string; status: string }, [string]>(
+
"SELECT filename, status FROM transcriptions WHERE id = ?",
.get(transcriptId);
-
if (transcription) {
-
whisperService.startTranscription(
-
transcriptId,
-
transcription.filename,
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
);
-
return Response.json({ success: true });
+
// 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 = ?", [
+
"selected",
+
transcriptId,
+
]);
+
+
whisperService.startTranscription(
+
transcriptId,
+
transcription.filename,
+
);
+
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
},
},
},
-
development: {
-
hmr: true,
-
console: true,
+
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");
});
+
+
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]);
+
});
+291 -81
src/lib/auth.ts
···
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]>(
+
.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 &&
+
subscription.status !== "canceled" &&
+
subscription.status !== "expired" &&
!subscription.cancel_at_period_end
) {
try {
···
"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],
-
);
+
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 } {
+
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]
-
>(
+
.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 {
+
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]
-
>(
+
.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(): 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 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(): 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();
+
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);
+
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 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);
+
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);
+240 -27
src/lib/classes.ts
···
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 function getClassesForUser(
userId: number,
isAdmin: boolean,
-
): ClassWithStats[] {
+
limit = 50,
+
cursor?: string,
+
): {
+
data: ClassWithStats[];
+
pagination: {
+
limit: number;
+
hasMore: boolean;
+
nextCursor: string | null;
+
};
+
} {
+
let classes: ClassWithStats[];
+
if (isAdmin) {
-
return db
-
.query<ClassWithStats, []>(
-
`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`,
-
)
-
.all();
+
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 db
-
.query<ClassWithStats, [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);
+
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 {
-
db.run("UPDATE classes SET archived = ? WHERE id = ?", [
+
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): void {
+
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, enrolled_at) VALUES (?, ?, ?)",
-
[classId, userId, now],
+
"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, filename, original_filename, status, progress, error_message, created_at, updated_at
+
`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 created_at DESC`,
+
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, enrolled_at) VALUES (?, ?, ?)",
-
).run(cls.id, userId, Math.floor(Date.now() / 1000));
+
"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("");
}
-
+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 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]);
+
});
+66 -2
src/lib/email-templates.ts
···
<p>Your transcription is ready!</p>
<div class="info-box">
-
${options.className ? `
+
${
+
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 { afterEach, beforeEach, describe, expect, test } from "bun:test";
import db from "../db/schema";
import {
-
createUser,
+
consumePasswordResetToken,
createEmailVerificationToken,
-
verifyEmailToken,
+
createPasswordResetToken,
+
createUser,
isEmailVerified,
-
createPasswordResetToken,
+
verifyEmailToken,
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,
-
]);
+
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);
+
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 { token } = createEmailVerificationToken(userId);
const result = verifyEmailToken(token);
expect(result).not.toBeNull();
···
});
test("token is one-time use", () => {
-
const token = createEmailVerificationToken(userId);
+
const { token } = createEmailVerificationToken(userId);
// First use succeeds
const firstResult = verifyEmailToken(token);
···
});
test("rejects expired token", () => {
-
const token = createEmailVerificationToken(userId);
+
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);
+
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],
-
);
+
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> {
+
// 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";
-
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",
-
);
-
}
+
// 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 {
+
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()}`]
-
);
+
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}`]
-
);
+
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);
+
expect(response.status).toBe(403);
const data = await response.json();
expect(data.error).toContain("subscription");
});
···
body: formData,
});
-
expect(response.status).toBe(500);
+
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",
-
},
-
);
+
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";
+
+
// 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",
-
);
+
// 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>
+
<link rel="stylesheet" href="../styles/admin.css">
</head>
<body>
···
<main>
<h1>Admin Dashboard</h1>
-
<div id="error-message" class="error" style="display: none;"></div>
+
<div id="error-message" class="error hidden"></div>
<div id="loading" class="loading">Loading...</div>
-
<div id="content" style="display: none;">
+
<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">
-
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>
+
<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>
+
<link rel="stylesheet" href="../styles/index.css">
</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>
+
<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();
+
}
+
});
+2 -20
src/pages/reset-password.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 {
-
display: flex;
-
align-items: center;
-
justify-content: center;
-
padding: 4rem 1rem;
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/reset-password.css">
</head>
<body>
···
<script type="module" src="../components/auth.ts"></script>
<script type="module" src="../components/reset-password-form.ts"></script>
-
<script type="module">
-
// 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.token = token;
-
}
-
</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>
+
<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>
+
<link rel="stylesheet" href="../styles/transcribe.css">
</head>
<body>
···
</header>
<main>
-
<div style="margin-bottom: 1rem;">
-
<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
+
<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;
+
}