feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time, timezone) with automatic 24h and 1h reminder emails plus a calendar invite (.ics), a time-change notification with an updated invite, a teacher-authored welcome email and session invite on enrollment, and a "Notify students" quiz nudge. LMS gets an upcoming live-sessions card and Join buttons, plus a reusable live-session card component (3 states) in @cio/ui with a Storybook story. The sidebar content tree now reflects sequential progression locking like the content list, and the course-completion confetti regression is fixed. Org-based emails no longer reference ClassroomIO by name. Docs add live-class, welcome-email, and course-progression guides, expand the certificates guide (design, signatures, corrected labels), move plan-required callouts to the top, flatten how-to-guides to the docs root with redirects, and add screenshots. Includes an add-docs-image skill.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
# Embed Link Platforms (Twitter/X + Vimeo)
|
||||
|
||||
## Purpose
|
||||
|
||||
Extend the lesson "Embed Link" video source to support platform-specific embeds (Twitter/X videos, Vimeo, and future platforms) instead of treating every URL as a raw HTML5 video source. The user selects the target platform from a dropdown; the system validates the URL, fetches metadata, and renders the appropriate embed player.
|
||||
|
||||
## Goals
|
||||
|
||||
- Redesign the Embed Link tab with a platform source dropdown + URL input.
|
||||
- Support Twitter/X video/post embedding via oEmbed + widgets.js.
|
||||
- Support Vimeo embedding via iframe player URL.
|
||||
- Keep the `generic` video type for raw HTML5 video URLs (Mp4, WebM).
|
||||
- Lay a pattern for adding future platforms (Loom, Wistia, etc.) without schema changes per platform.
|
||||
- Rename the tab from "Embed Link" to "Embed" to reflect the broader scope.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding a dedicated tab per platform in the add-video modal.
|
||||
- Implementing watch enforcement or transcript support for embed platforms (they render as iframes, not HTML5 video).
|
||||
- Supporting Twitter/X as a downloadable video source.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Existing `generic` type behavior must be preserved (HTML5 `<video>` via Plyr) when no platform is selected.
|
||||
- Platform selection is stored in a new top-level `platform` field on the video object, not inside `metadata`.
|
||||
- Asset `provider` uses the platform name (e.g. `'twitter'`, `'vimeo'`) so canonical asset metadata can be enriched later.
|
||||
- `mapProviderToVideoType` in `lesson-media.ts` maps these providers to `'generic'` type for backward compatibility.
|
||||
- Twitter oEmbed fetch requires a server-side proxy (Twitter's oEmbed API does not support CORS).
|
||||
- Vimeo oEmbed is optional (the embed URL can be constructed from the video ID directly).
|
||||
|
||||
## UX Design
|
||||
|
||||
### Embed Link Tab (renamed to "Embed")
|
||||
|
||||
```
|
||||
┌─ Source ──────────────────────┐
|
||||
│ [HTML5 Video ▼] │
|
||||
│ ───────────────────── │
|
||||
│ HTML5 Video │
|
||||
│ Twitter / X │
|
||||
│ Vimeo │
|
||||
└───────────────────────────────┘
|
||||
|
||||
[URL: ___________________________________]
|
||||
|
||||
[Add Video]
|
||||
```
|
||||
|
||||
- **HTML5 Video (default)**: Current behavior. Placeholder: `https://example.com/video.mp4`. Validates basic URL format.
|
||||
- **Twitter / X**: Placeholder: `https://x.com/username/status/123456789`. Validates tweet URL pattern. Fetches oEmbed metadata on add.
|
||||
- **Vimeo**: Placeholder: `https://vimeo.com/123456789`. Validates Vimeo video URL. Fetches oEmbed metadata on add (or constructs embed URL from video ID).
|
||||
|
||||
### Display in lesson
|
||||
|
||||
- Video card shows the platform icon/name (e.g. "Twitter / X · Feb 15, 2026").
|
||||
- Thumbnail from oEmbed metadata (Twitter returns `thumbnail_url`; Vimeo returns `thumbnail_url`).
|
||||
- View mode renders the platform-specific embed (iframe for Twitter via widgets.js; iframe for Vimeo player).
|
||||
|
||||
## Schema Changes
|
||||
|
||||
### Database (`packages/db/src/schema.ts`)
|
||||
|
||||
Add `platform` to the videos JSONB type union:
|
||||
|
||||
```typescript
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
platform?: 'twitter' | 'vimeo'; // NEW: embed platform discriminator
|
||||
link: string;
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
watchEnforced?: boolean;
|
||||
fileName?: string;
|
||||
metadata?: {
|
||||
svid?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
thumbnailUrl?: string;
|
||||
duration?: number;
|
||||
aspectRatio?: string;
|
||||
createdAt?: string;
|
||||
// NEW (stored from oEmbed):
|
||||
embedHtml?: string; // Twitter oEmbed HTML (omit_script=true)
|
||||
authorName?: string; // Twitter: @username / Vimeo: uploader name
|
||||
videoId?: string; // Vimeo: numeric video ID
|
||||
};
|
||||
```
|
||||
|
||||
### Validation (`packages/utils/src/validation/lesson/lesson.ts`)
|
||||
|
||||
```typescript
|
||||
platform: z.enum(['twitter', 'vimeo']).optional(),
|
||||
```
|
||||
|
||||
### Media Player Types (`packages/ui/src/custom/media-player/types.ts`)
|
||||
|
||||
```typescript
|
||||
export interface VideoSource {
|
||||
type: VideoType;
|
||||
url: string;
|
||||
platform?: 'twitter' | 'vimeo'; // NEW
|
||||
hls?: boolean;
|
||||
metadata?: {
|
||||
svid?: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
embedHtml?: string; // NEW: for Twitter oEmbed HTML
|
||||
authorName?: string; // NEW
|
||||
videoId?: string; // NEW: for Vimeo
|
||||
};
|
||||
tracks?: VideoTextTrack[];
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### 1. Twitter oEmbed Utility (`packages/core/src/utils/twitter-embed.ts` — NEW)
|
||||
|
||||
```typescript
|
||||
export function getTweetId(url: string): string | null
|
||||
// Extracts tweet ID from:
|
||||
// https://x.com/{user}/status/{id}
|
||||
// https://twitter.com/{user}/status/{id}
|
||||
// https://x.com/{user}/status/{id}?param=value
|
||||
// https://twitter.com/{user}/status/{id}/photo/1 (strips suffix)
|
||||
|
||||
export async function fetchTwitterOEmbed(url: string): Promise<{
|
||||
html: string;
|
||||
author_name: string;
|
||||
title: string;
|
||||
thumbnail_url: string;
|
||||
cache_age: number;
|
||||
} | null>
|
||||
// Fetches https://publish.x.com/oembed?url={encodedUrl}&omit_script=true
|
||||
// Returns parsed JSON or null on failure.
|
||||
```
|
||||
|
||||
### 2. Vimeo Utility (`packages/core/src/utils/vimeo-embed.ts` — NEW)
|
||||
|
||||
```typescript
|
||||
export function getVimeoVideoId(url: string): string | null
|
||||
// Extracts video ID from:
|
||||
// https://vimeo.com/{id}
|
||||
// https://vimeo.com/{id}?param=value
|
||||
// https://vimeo.com/channels/{channel}/{id}
|
||||
// https://vimeo.com/album/{album}/video/{id}
|
||||
// https://player.vimeo.com/video/{id}
|
||||
|
||||
export function formatVimeoEmbedUrl(videoId: string): string
|
||||
// Returns https://player.vimeo.com/video/{videoId}
|
||||
|
||||
export async function fetchVimeoOEmbed(url: string): Promise<{
|
||||
title: string;
|
||||
thumbnail_url: string;
|
||||
author_name: string;
|
||||
duration: number;
|
||||
} | null>
|
||||
// Optionally fetches https://vimeo.com/api/oembed.json?url={encodedUrl}
|
||||
// Embed URL can be constructed from videoId alone (oEmbed is optional).
|
||||
```
|
||||
|
||||
### 3. Asset Provider Mapping (`packages/core/src/utils/lesson-media.ts`)
|
||||
|
||||
Update `mapProviderToVideoType`:
|
||||
|
||||
```typescript
|
||||
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
if (provider === 'generic' || provider === 'external_url') return 'generic';
|
||||
if (provider === 'twitter' || provider === 'vimeo') return 'generic'; // NEW
|
||||
return 'upload';
|
||||
}
|
||||
```
|
||||
|
||||
### 4. API Route — Twitter oEmbed Proxy (`apps/api/src/routes/media/twitter.ts` — NEW)
|
||||
|
||||
```
|
||||
GET /api/media/twitter/oembed?url={tweetUrl}
|
||||
```
|
||||
|
||||
Proxies `https://publish.x.com/oembed?url={url}&omit_script=true`. Returns oEmbed JSON directly. This avoids CORS issues when fetching from the browser.
|
||||
|
||||
### 5. API Route — Vimeo Embed Proxy (`apps/api/src/routes/media/vimeo.ts` — NEW)
|
||||
|
||||
```
|
||||
GET /api/media/vimeo/oembed?url={vimeoUrl}
|
||||
```
|
||||
|
||||
Proxies `https://vimeo.com/api/oembed.json?url={url}`. Returns oEmbed JSON directly.
|
||||
|
||||
### 6. Register Routes (`apps/api/src/routes/media/index.ts`)
|
||||
|
||||
Mount the new twitter and vimeo routers.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. Embed Link Tab Redesign (`apps/dashboard/.../embed-link.svelte`)
|
||||
|
||||
Major rewrite:
|
||||
|
||||
- Add platform selector dropdown above the URL input.
|
||||
- Dropdown options: `HTML5 Video`, `Twitter / X`, `Vimeo`.
|
||||
- On platform change: update URL input placeholder, clear validation state.
|
||||
- On submit:
|
||||
- **HTML5 Video**: current behavior (type: `'generic'`, no platform).
|
||||
- **Twitter**: validate tweet URL → fetch oEmbed via API → create asset with `provider: 'twitter'` → store video with `type: 'generic', platform: 'twitter', metadata: { embedHtml, authorName, thumbnailUrl, title, createdAt }`.
|
||||
- **Vimeo**: validate Vimeo URL → optionally fetch oEmbed → create asset with `provider: 'vimeo'` → store video with `type: 'generic', platform: 'vimeo', metadata: { videoId, title, thumbnailUrl, authorName, duration, createdAt }`.
|
||||
|
||||
Validation helpers (new file or inline):
|
||||
|
||||
```typescript
|
||||
const TWITTER_URL_RE = /^https?:\/\/(x\.com|twitter\.com)\/[^/]+\/status\/(\d+)/;
|
||||
const VIMEO_URL_RE = /^https?:\/\/(www\.)?vimeo\.com\/(\d+)/;
|
||||
|
||||
function isValidTwitterUrl(url: string): boolean { ... }
|
||||
function isValidVimeoUrl(url: string): boolean { ... }
|
||||
```
|
||||
|
||||
### 2. Media Player (`packages/ui/src/custom/media-player/media-player.svelte`)
|
||||
|
||||
Add platform branching alongside the existing type branches:
|
||||
|
||||
```
|
||||
if (source.type === 'muse' && source.metadata?.svid) → MusePlayer
|
||||
else if (source.type === 'google_drive') → iframe
|
||||
else if (source.type === 'youtube') → iframe (formatYoutubeEmbedUrl)
|
||||
else if (source.type === 'generic' && source.platform === 'twitter') → Twitter embed
|
||||
else if (source.type === 'generic' && source.platform === 'vimeo') → Vimeo embed
|
||||
else → PlyrPlayer (HTML5 video)
|
||||
```
|
||||
|
||||
**Twitter render**:
|
||||
- Container `<div bind:this={container}>` with a `<blockquote class="twitter-tweet">` containing `<a href={source.url}>`.
|
||||
- Load `widgets.js` once on page (module-level flag).
|
||||
- On mount / when source changes, call `twttr.widgets.load(container)`.
|
||||
|
||||
**Vimeo render**:
|
||||
- Iframe with `src={formatVimeoEmbedUrl(source.metadata.videoId)}`.
|
||||
- Same styling/constraints as YouTube embed (aspect-ratio, fullscreen, etc.).
|
||||
|
||||
### 3. Video Card Utils (`.../video-card-utils.ts`)
|
||||
|
||||
Update platform-aware helpers:
|
||||
|
||||
- `getVideoTitle`: Add `'twitter'` → "X / Twitter post", `'vimeo'` → "Vimeo video" (or use title from metadata).
|
||||
- `getVideoThumbnailUrl`: Add Twitter case (uses `metadata.thumbnailUrl` from oEmbed), Vimeo case (uses `metadata.thumbnailUrl` from oEmbed).
|
||||
|
||||
### 4. Video Card (`.../lesson-video-simple-card.svelte`)
|
||||
|
||||
Update `sourceKindLabel`:
|
||||
|
||||
```typescript
|
||||
const key =
|
||||
video.type === 'youtube' ? 'kind_youtube' :
|
||||
video.type === 'upload' ? 'kind_upload' :
|
||||
video.type === 'google_drive' ? 'kind_google_drive' :
|
||||
video.platform === 'twitter' ? 'kind_twitter' :
|
||||
video.platform === 'vimeo' ? 'kind_vimeo' :
|
||||
'kind_generic';
|
||||
```
|
||||
|
||||
### 5. Video Player (`.../lesson-video-player.svelte`)
|
||||
|
||||
Pass platform info to MediaPlayer:
|
||||
|
||||
```typescript
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: playbackUrl,
|
||||
platform: (video as any).platform, // NEW
|
||||
hls: isHls,
|
||||
metadata: { ...video.metadata, embedHtml: ..., authorName: ..., videoId: ... },
|
||||
tracks
|
||||
}}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
### 6. Constants (`.../video/constants.ts`)
|
||||
|
||||
Update tab name from "Embed Link" to "Embed":
|
||||
|
||||
```typescript
|
||||
{
|
||||
value: 2,
|
||||
title: 'course.navItem.lessons.materials.tabs.video.embed',
|
||||
icon: FileSymlinkIcon
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Add-Video Modal (`add-video-modal.svelte`)
|
||||
|
||||
Import remains the same (embed-link.svelte is the component; no new imports needed).
|
||||
|
||||
### 8. Filters in Video Card List (`embed-link.svelte` and `video.svelte`)
|
||||
|
||||
The existing video list filters by `video.type === 'generic'` in embed-link.svelte and shows all types in video.svelte. Since Twitter/Vimeo use `type: 'generic'`, they'll naturally show up in both places without filter changes.
|
||||
|
||||
## Media API Changes
|
||||
|
||||
### `media-manager.svelte.ts`
|
||||
|
||||
No structural changes needed — `createAsset` already accepts arbitrary `provider` and `metadata` strings. The embed-link component will call it with `provider: 'twitter'` or `provider: 'vimeo'` directly.
|
||||
|
||||
## Translation Keys (en.json)
|
||||
|
||||
Under `course.navItem.lessons.materials.tabs.video`:
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `embed` | "Embed" (renamed from `embed_link`) |
|
||||
| `add_video.source_label` | "Source" |
|
||||
| `add_video.source_html5` | "HTML5 Video" |
|
||||
| `add_video.source_twitter` | "Twitter / X" |
|
||||
| `add_video.source_vimeo` | "Vimeo" |
|
||||
| `add_video.twitter_placeholder` | "https://x.com/username/status/..." |
|
||||
| `add_video.vimeo_placeholder` | "https://vimeo.com/..." |
|
||||
| `add_video.invalid_twitter` | "Invalid Twitter/X link" |
|
||||
| `add_video.invalid_vimeo` | "Invalid Vimeo link" |
|
||||
| `simple_card.kind_twitter` | "Twitter / X" |
|
||||
| `simple_card.kind_vimeo` | "Vimeo" |
|
||||
|
||||
## Extensibility
|
||||
|
||||
Adding a new platform (e.g. Loom) requires:
|
||||
|
||||
1. Add `'loom'` to the `platform` enum in schema + validation.
|
||||
2. Create a utility in `packages/core/src/utils/` for URL parsing/oEmbed.
|
||||
3. Add an API proxy route in `apps/api/src/routes/media/`.
|
||||
4. Add the platform to the dropdown in `embed-link.svelte`.
|
||||
5. Add a render branch in `media-player.svelte`.
|
||||
6. Add translation keys.
|
||||
|
||||
No DB migration needed (the `platform` field is optional on `generic` type).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we store the full oEmbed HTML for Twitter in the database, or reconstruct the `<blockquote>` markup from the tweet URL at render time? Reconstructing is simpler and avoids storing large HTML blobs. The `widgets.js` approach works with just the URL.
|
||||
- Vimeo: skip oEmbed entirely and just construct `player.vimeo.com/video/{id}` from the URL? Saves an API call.
|
||||
- Mobile responsiveness: Twitter embeds via widgets.js handle responsive sizing natively. Vimeo iframes need aspect-ratio handling (same as YouTube).
|
||||
@@ -0,0 +1,430 @@
|
||||
# Super Admin Panel PRD
|
||||
|
||||
## Purpose
|
||||
|
||||
Build a password-gated super admin panel that lets the ClassroomIO operator manage all users across the database without needing a user account. Use cases:
|
||||
|
||||
1. **User management** — view a paginated, searchable table of every user; open individual profile pages with stats
|
||||
2. **AI chat inspection** — view all AI assistant conversations for any user and copy an entire chat transcript with one click
|
||||
3. **Credit management** — top up AI credits for any org directly from the panel
|
||||
4. **Org creation** — create a new organization and assign an existing user as its admin
|
||||
5. **Impersonation** — "Login as" any user to act on their behalf on `app.classroomio.com`
|
||||
|
||||
The panel has no user account of its own. Access is controlled by a single `SUPER_ADMIN_PASSWORD` env var, mirroring the existing `/admin/queues` pattern. It ships as a **separate app** (`apps/admin/`) so it never bloats the user-facing dashboard bundle and can be deployed independently.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
1. **Separate app**: `apps/admin/` — own SvelteKit app, own `package.json`, own deployment. Zero code shared with `apps/dashboard/` at the bundle level.
|
||||
2. **Auth pattern**: `X-Super-Admin-Key` request header checked against `SUPER_ADMIN_PASSWORD` env var via timing-safe comparison on the API side. Same approach as `QUEUE_DASHBOARD_PASSWORD` in `apps/api/src/routes/admin/queues.ts`.
|
||||
3. **Password gate UX**: Custom login form in the admin app. Password stored in `sessionStorage` — survives page refresh, cleared on tab close.
|
||||
4. **API proxy**: `apps/admin/src/hooks.server.ts` forwards `/api/*` → `PRIVATE_SERVER_URL`. Client code calls `/api/admin/users` etc. No tenant routing complexity needed — this is internal only.
|
||||
5. **Impersonation**: Reuses `mintLoginLinkToken` from `packages/db/src/auth/login-link.ts`. Admin opens the resulting URL (`https://app.classroomio.com/api/auth/login-link?token=...&redirect=/home`) in a new tab. TTL: 60 minutes.
|
||||
6. **AI credits**: `aiCreditBalance` is per-org. The UI shows a user's admin orgs with current credit balance; operator enters an amount to add. Calls existing `upsertCreditBalance` query.
|
||||
7. **Org creation**: Reuses `createOrganization` + `createOrganizationMember` query pattern, accepting a target `userId` instead of the session user.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph adminApp ["apps/admin/ (standalone SvelteKit)"]
|
||||
Gate[Password Gate\nsessionStorage]
|
||||
UsersPage[Users Table]
|
||||
ProfilePage[User Profile\n+ Chats + Credits]
|
||||
NewOrgPage[Create Org Form]
|
||||
Proxy["hooks.server.ts\n/api/* → PRIVATE_SERVER_URL"]
|
||||
end
|
||||
|
||||
subgraph api ["apps/api/ /admin/*"]
|
||||
Middleware[superAdminMiddleware\nX-Super-Admin-Key]
|
||||
UsersRouter[/admin/users/**]
|
||||
OrgsRouter[/admin/orgs/**]
|
||||
end
|
||||
|
||||
subgraph existing [Existing packages]
|
||||
LoginLink[mintLoginLinkToken]
|
||||
CreditBalance[upsertCreditBalance]
|
||||
OrgCreate[createOrganization]
|
||||
end
|
||||
|
||||
Gate -->|header via Proxy| Middleware
|
||||
Middleware --> UsersRouter
|
||||
Middleware --> OrgsRouter
|
||||
UsersRouter -->|impersonate| LoginLink
|
||||
OrgsRouter -->|credits| CreditBalance
|
||||
OrgsRouter -->|create| OrgCreate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: API Layer
|
||||
|
||||
### 1a. Env Config
|
||||
|
||||
**`packages/core/src/config/env.ts`** — add alongside `QUEUE_DASHBOARD_PASSWORD`:
|
||||
|
||||
```typescript
|
||||
SUPER_ADMIN_PASSWORD: z.string().optional(),
|
||||
```
|
||||
|
||||
### 1b. Super Admin Middleware
|
||||
|
||||
**`apps/api/src/middlewares/super-admin.ts`** (new file)
|
||||
|
||||
```typescript
|
||||
import type { Context, Next } from 'hono';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
export async function superAdminMiddleware(c: Context, next: Next): Promise<Response | void> {
|
||||
const expected = env.SUPER_ADMIN_PASSWORD;
|
||||
|
||||
if (!expected) {
|
||||
if (env.NODE_ENV !== 'production') return next();
|
||||
return c.json({ success: false, error: 'Super admin not configured' }, 503);
|
||||
}
|
||||
|
||||
const key = c.req.header('X-Super-Admin-Key');
|
||||
if (!key || !timingSafeEqual(key, expected)) {
|
||||
return c.json({ success: false, error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let mismatch = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return mismatch === 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 1c. DB Queries
|
||||
|
||||
**`packages/db/src/queries/admin/users.ts`** (new file)
|
||||
|
||||
```typescript
|
||||
listUsers({ page, limit, search }: { page: number; limit: number; search?: string })
|
||||
→ Promise<{ users: AdminUserRow[]; total: number }>
|
||||
```
|
||||
- LEFT JOIN `user` + `profile` on `profile.id = user.id`
|
||||
- Subquery count of `organizationmember` per user → `orgCount`
|
||||
- Subquery count of `groupmember` per profile → `courseCount`
|
||||
- WHERE `user.name ILIKE '%search%' OR user.email ILIKE '%search%'` when search provided
|
||||
- ORDER BY `user.createdAt DESC`, LIMIT / OFFSET for pagination
|
||||
|
||||
```typescript
|
||||
getUserWithStats(userId: string) → Promise<AdminUserWithStats | null>
|
||||
```
|
||||
- Same joins plus: admin org count (`roleId = 1`), total org count, total course enrollments
|
||||
- Include each admin org's `aiCreditBalance` (for the credits section)
|
||||
|
||||
**`packages/db/src/queries/admin/chats.ts`** (new file)
|
||||
|
||||
```typescript
|
||||
listUserChats(userId: string) → Promise<ChatSummary[]>
|
||||
// SELECT id, title, course_id, updated_at, jsonb_array_length(messages) as message_count
|
||||
// FROM ai_chat_conversation WHERE user_id = userId ORDER BY updated_at DESC
|
||||
|
||||
getUserChat(conversationId: string, userId: string) → Promise<ChatConversation | null>
|
||||
// Full row including messages JSONB WHERE id = conversationId AND user_id = userId
|
||||
```
|
||||
|
||||
**`packages/db/src/queries/admin/orgs.ts`** (new file)
|
||||
|
||||
```typescript
|
||||
createOrgWithAdmin({ name, siteName, profileId })
|
||||
→ Promise<{ org: Organization; member: OrganizationMember }>
|
||||
// Transaction: createOrganization + createOrganizationMember(roleId = ROLE.ADMIN, verified = true)
|
||||
// Same pattern as apps/api/src/services/organization.ts
|
||||
```
|
||||
|
||||
### 1d. Validation
|
||||
|
||||
**`packages/utils/src/validation/admin/admin.ts`** (new file)
|
||||
|
||||
```typescript
|
||||
export const ZListUsersQuery = z.object({
|
||||
page: z.string().transform(Number).pipe(z.number().min(1)).default('1'),
|
||||
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('20'),
|
||||
search: z.string().optional()
|
||||
});
|
||||
|
||||
export const ZAddCredits = z.object({ amount: z.number().int().positive() });
|
||||
|
||||
export const ZCreateAdminOrg = z.object({
|
||||
name: z.string().min(5),
|
||||
siteName: z.string().min(5),
|
||||
ownerUserId: z.string().uuid()
|
||||
});
|
||||
```
|
||||
|
||||
### 1e. Services
|
||||
|
||||
**`apps/api/src/services/admin/users.ts`** (new file)
|
||||
- `listAdminUsers(params)` — calls `listUsers` query
|
||||
- `getAdminUserStats(userId)` — calls `getUserWithStats`; throws `AppError` 404 if not found
|
||||
- `listAdminUserChats(userId)` — calls `listUserChats`
|
||||
- `getAdminUserChat(conversationId, userId)` — calls `getUserChat`; throws 404 if not found
|
||||
- `impersonateUser(userId)` — fetches `user.email` from DB, calls `mintLoginLinkToken({ userId, email, ttlMinutes: 60 })`, returns `{ loginUrl }`
|
||||
|
||||
**`apps/api/src/services/admin/orgs.ts`** (new file)
|
||||
- `addOrgCredits(orgId, amount)` — calls `upsertCreditBalance(orgId, amount)` from `packages/db/src/queries/agent/token-usage.ts`
|
||||
- `createOrgForUser(data)` — resolves `ownerUserId` → `profileId`, checks siteName uniqueness (409 if taken), calls `createOrgWithAdmin`
|
||||
|
||||
### 1f. Routes
|
||||
|
||||
**`apps/api/src/routes/admin/users.ts`** (new file)
|
||||
```
|
||||
GET /users listAdminUsers (ZListUsersQuery)
|
||||
GET /users/:userId getAdminUserStats
|
||||
POST /users/:userId/impersonate impersonateUser → { loginUrl }
|
||||
GET /users/:userId/chats listAdminUserChats
|
||||
GET /users/:userId/chats/:chatId getAdminUserChat
|
||||
```
|
||||
|
||||
**`apps/api/src/routes/admin/orgs.ts`** (new file)
|
||||
```
|
||||
POST /orgs createOrgForUser (ZCreateAdminOrg)
|
||||
POST /orgs/:orgId/credits addOrgCredits (ZAddCredits)
|
||||
```
|
||||
|
||||
**`apps/api/src/routes/admin/index.ts`** (new file)
|
||||
```typescript
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { superAdminMiddleware } from '@api/middlewares/super-admin';
|
||||
import { adminUsersRouter } from './users';
|
||||
import { adminOrgsRouter } from './orgs';
|
||||
|
||||
export const adminRouter = new Hono()
|
||||
.use('/*', superAdminMiddleware)
|
||||
.route('/', adminUsersRouter)
|
||||
.route('/', adminOrgsRouter);
|
||||
```
|
||||
|
||||
### 1g. Register in app.ts
|
||||
|
||||
```typescript
|
||||
import { adminRouter } from '@api/routes/admin';
|
||||
// in the RPC chain:
|
||||
.route('/admin', adminRouter)
|
||||
```
|
||||
|
||||
`mountQueueDashboard(app)` stays untouched — its `/admin/queues/*` paths don't conflict.
|
||||
|
||||
### 1h. Build
|
||||
|
||||
```bash
|
||||
pnpm --filter @cio/api build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: apps/admin — Standalone SvelteKit App
|
||||
|
||||
### 2a. App scaffold
|
||||
|
||||
**`apps/admin/`** — new workspace package
|
||||
|
||||
```
|
||||
apps/admin/
|
||||
package.json
|
||||
svelte.config.js
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
.env.example
|
||||
src/
|
||||
app.html
|
||||
hooks.server.ts ← forwards /api/* to PRIVATE_SERVER_URL
|
||||
routes/
|
||||
+layout.svelte ← password gate + nav
|
||||
+layout.ts ← ssr: false
|
||||
+page.svelte ← redirect to /users
|
||||
users/
|
||||
+page.svelte ← paginated users table
|
||||
[userId]/
|
||||
+page.svelte ← user profile
|
||||
orgs/
|
||||
new/
|
||||
+page.svelte ← create org form
|
||||
lib/
|
||||
api.svelte.ts ← AdminApi class
|
||||
types.ts ← plain TS types (no RPC inference — standalone app)
|
||||
```
|
||||
|
||||
**`package.json`**:
|
||||
```json
|
||||
{
|
||||
"name": "@cio/admin",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5174",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node build/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/kit": "...",
|
||||
"@sveltejs/adapter-node": "...",
|
||||
"svelte": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`svelte.config.js`**: adapter-node, no CSP complexity, alias `$lib` → `./src/lib`
|
||||
|
||||
**`.env.example`**:
|
||||
```
|
||||
PRIVATE_SERVER_URL=http://localhost:3002
|
||||
PUBLIC_APP_URL=https://app.classroomio.com
|
||||
```
|
||||
|
||||
### 2b. API proxy
|
||||
|
||||
**`src/hooks.server.ts`**
|
||||
|
||||
```typescript
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const API_PREFIX = '/api';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith(API_PREFIX)) {
|
||||
const upstream = process.env.PRIVATE_SERVER_URL;
|
||||
if (!upstream) return new Response('API not configured', { status: 502 });
|
||||
|
||||
const upstreamUrl = new URL(
|
||||
event.url.pathname.slice(API_PREFIX.length) + event.url.search,
|
||||
upstream
|
||||
);
|
||||
|
||||
const headers = new Headers(event.request.headers);
|
||||
headers.set('host', upstreamUrl.host);
|
||||
|
||||
return fetch(upstreamUrl, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' ? event.request.body : undefined,
|
||||
// @ts-ignore
|
||||
duplex: 'half'
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
```
|
||||
|
||||
### 2c. API class
|
||||
|
||||
**`src/lib/api.svelte.ts`**
|
||||
|
||||
`AdminApi` class — stores `password: string`, appends `'X-Super-Admin-Key': password` header to every fetch.
|
||||
|
||||
Methods:
|
||||
- `listUsers(page, search?)` → `GET /api/admin/users`
|
||||
- `getUser(userId)` → `GET /api/admin/users/:userId`
|
||||
- `loginAsUser(userId)` → `POST /api/admin/users/:userId/impersonate` → `window.open(data.loginUrl, '_blank')`
|
||||
- `listChats(userId)` → `GET /api/admin/users/:userId/chats`
|
||||
- `getChat(userId, chatId)` → `GET /api/admin/users/:userId/chats/:chatId`
|
||||
- `addCredits(orgId, amount)` → `POST /api/admin/orgs/:orgId/credits`
|
||||
- `createOrg(data)` → `POST /api/admin/orgs`
|
||||
|
||||
Uses plain `fetch` (no Hono RPC client — standalone app has no `@cio/api` dep). Types defined locally in `src/lib/types.ts`.
|
||||
|
||||
### 2d. Password gate layout
|
||||
|
||||
**`src/routes/+layout.ts`**: `export const ssr = false;`
|
||||
|
||||
**`src/routes/+layout.svelte`**:
|
||||
- On mount: read `sessionStorage.getItem('super-admin-pw')`
|
||||
- If absent: render centered password form (input + submit button)
|
||||
- On submit: `GET /api/admin/users?limit=1` with `X-Super-Admin-Key` header
|
||||
- 401 → show inline error
|
||||
- 200 → store in sessionStorage, show children
|
||||
- Pass password to all child pages via Svelte context (`setContext`)
|
||||
- Top nav: "ClassroomIO Admin" | Users | Create Org | Sign out (clears sessionStorage + reloads)
|
||||
|
||||
### 2e. Pages
|
||||
|
||||
**`/` (+page.svelte)**: redirect to `/users`
|
||||
|
||||
**`/users` (+page.svelte)** — Users table
|
||||
|
||||
DataTable columns: Avatar | Name | Email | Joined | Orgs | Courses | Verified | Banned | Actions
|
||||
|
||||
- Search input (debounced 300 ms), pagination controls
|
||||
- Row actions: "View" → `/users/[id]`; "Login as" inline button
|
||||
|
||||
**`/users/[userId]` (+page.svelte)** — User profile
|
||||
|
||||
1. **Header**: avatar, full name, email, joined date, verified/banned badges + "Login as user" button
|
||||
2. **Stats grid** (4 tiles): Orgs (member), Orgs (admin), Courses enrolled, Account status
|
||||
3. **AI Chats**: conversation list → click to expand inline messages → "Copy chat" button formats as plain text and calls `navigator.clipboard.writeText`
|
||||
4. **Orgs & Credits**: table of admin orgs with current credit balance + "Add credits" per row (inline number input → confirm)
|
||||
|
||||
**`/orgs/new` (+page.svelte)** — Create org
|
||||
|
||||
Fields: Org name, Site name, Owner user ID. On submit → success state with "Login as owner" shortcut.
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
### API (apps/api)
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `packages/core/src/config/env.ts` | +1 line: `SUPER_ADMIN_PASSWORD` |
|
||||
| `packages/db/src/queries/admin/users.ts` | `listUsers`, `getUserWithStats` |
|
||||
| `packages/db/src/queries/admin/chats.ts` | `listUserChats`, `getUserChat` |
|
||||
| `packages/db/src/queries/admin/orgs.ts` | `createOrgWithAdmin` |
|
||||
| `packages/utils/src/validation/admin/admin.ts` | `ZListUsersQuery`, `ZAddCredits`, `ZCreateAdminOrg` |
|
||||
| `apps/api/src/middlewares/super-admin.ts` | `superAdminMiddleware` |
|
||||
| `apps/api/src/services/admin/users.ts` | User + chat + impersonation services |
|
||||
| `apps/api/src/services/admin/orgs.ts` | Credits + org creation services |
|
||||
| `apps/api/src/routes/admin/users.ts` | User/chat/impersonate routes |
|
||||
| `apps/api/src/routes/admin/orgs.ts` | Org/credits routes |
|
||||
| `apps/api/src/routes/admin/index.ts` | Aggregate admin router |
|
||||
|
||||
### API (modified)
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `apps/api/src/app.ts` | Add `import { adminRouter }` + `.route('/admin', adminRouter)` |
|
||||
|
||||
### apps/admin (all new)
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `apps/admin/package.json` | Package config (`@cio/admin`) |
|
||||
| `apps/admin/svelte.config.js` | adapter-node, `$lib` alias |
|
||||
| `apps/admin/vite.config.ts` | Vite + SvelteKit |
|
||||
| `apps/admin/tsconfig.json` | TypeScript config |
|
||||
| `apps/admin/.env.example` | `PRIVATE_SERVER_URL`, `PUBLIC_APP_URL` |
|
||||
| `apps/admin/src/app.html` | HTML shell |
|
||||
| `apps/admin/src/hooks.server.ts` | `/api/*` → `PRIVATE_SERVER_URL` proxy |
|
||||
| `apps/admin/src/lib/api.svelte.ts` | `AdminApi` class |
|
||||
| `apps/admin/src/lib/types.ts` | Request/response types |
|
||||
| `apps/admin/src/routes/+layout.ts` | `ssr = false` |
|
||||
| `apps/admin/src/routes/+layout.svelte` | Password gate + nav |
|
||||
| `apps/admin/src/routes/+page.svelte` | Redirect to /users |
|
||||
| `apps/admin/src/routes/users/+page.svelte` | Users table |
|
||||
| `apps/admin/src/routes/users/[userId]/+page.svelte` | User profile |
|
||||
| `apps/admin/src/routes/orgs/new/+page.svelte` | Create org |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd apps/admin && pnpm dev` — app starts on port 5174
|
||||
2. Navigate to `localhost:5174` — password gate form appears
|
||||
3. Wrong password → inline error; correct password → land on `/users`
|
||||
4. User table loads with search and pagination
|
||||
5. Open a user profile → stats, chats, and orgs sections load
|
||||
6. Expand an AI chat → messages render; "Copy chat" copies plain-text transcript
|
||||
7. Add credits to an org → balance refreshes
|
||||
8. Create an org via `/orgs/new` → verify row in DB
|
||||
9. "Login as user" → new tab opens at `app.classroomio.com` as that user
|
||||
10. `pnpm build` in `apps/admin/` — builds independently with no dashboard code in the bundle
|
||||
Reference in New Issue
Block a user