- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
296 lines
12 KiB
Markdown
296 lines
12 KiB
Markdown
# Agent Guidance
|
||
|
||
This document collects implementation rules and workflow conventions for code changes.
|
||
|
||
## Naming Convention
|
||
|
||
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
||
|
||
## Creating a New Route
|
||
|
||
### Step 1: Validation Schema
|
||
```typescript
|
||
// packages/utils/src/validation/account/account.ts
|
||
export const ZUpdateProfile = z.object({
|
||
fullname: z.string().min(5).optional(),
|
||
username: z.string().min(1).optional()
|
||
});
|
||
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
|
||
```
|
||
|
||
### Step 2: Service
|
||
```typescript
|
||
// apps/api/src/services/account.ts
|
||
export async function updateUser(userId: string, data: Partial<TProfile>) {
|
||
const updated = await updateProfile(userId, data);
|
||
if (!updated) throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||
return updated;
|
||
}
|
||
```
|
||
|
||
### Step 3: Route
|
||
Routes must return a single type (or a response shape whose `data` is a single type). If multiple shapes are needed, use separate endpoints or a keyed structure (e.g. `data` indexed by a key) so clients get one type per endpoint or key. Do not return a union of shapes from one route based on query parameters.
|
||
|
||
```typescript
|
||
// apps/api/src/routes/account/account.ts
|
||
export const accountRouter = new Hono().put(
|
||
'/user',
|
||
authMiddleware,
|
||
zValidator('json', ZUpdateProfile),
|
||
async (c) => {
|
||
try {
|
||
const user = c.get('user')!;
|
||
const data = c.req.valid('json');
|
||
const result = await updateUser(user.id, data);
|
||
return c.json({ success: true, data: result });
|
||
} catch (error) {
|
||
return handleError(c, error, 'Failed to update profile');
|
||
}
|
||
}
|
||
);
|
||
```
|
||
|
||
### Step 4: Register
|
||
```typescript
|
||
// apps/api/src/routes/account/index.ts
|
||
export * from './account';
|
||
|
||
// apps/api/src/app.ts
|
||
import { accountRouter } from '@api/routes/account';
|
||
export const app = new Hono().route('/account', accountRouter);
|
||
```
|
||
|
||
### Step 5: Build
|
||
```bash
|
||
pnpm --filter @cio/api build
|
||
```
|
||
|
||
### Step 6: Frontend Types & API Class
|
||
```typescript
|
||
// apps/dashboard/src/lib/features/account/utils/types.ts
|
||
import { classroomio } from '$lib/utils/services/api';
|
||
|
||
export type UpdateProfileRequest = typeof classroomio.account.user.$put;
|
||
```
|
||
|
||
```typescript
|
||
// apps/dashboard/src/lib/features/account/api/account.svelte.ts
|
||
import type { UpdateProfileRequest } from '../utils/types';
|
||
|
||
class AccountApi extends BaseApiWithErrors {
|
||
async updateProfile(fields: TUpdateProfile) {
|
||
const result = ZUpdateProfile.safeParse(fields);
|
||
if (!result.success) {
|
||
this.errors = mapZodErrorsToTranslations(result.error);
|
||
return;
|
||
}
|
||
await this.execute<UpdateProfileRequest>({
|
||
requestFn: () => classroomio.account.user.$put({ json: result.data }),
|
||
logContext: 'updating profile',
|
||
onSuccess: () => {
|
||
snackbar.success('Profile updated');
|
||
this.success = true;
|
||
},
|
||
onError: (result) => {
|
||
if ('field' in result) this.errors[result.field] = result.error;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
## Frontend Conventions
|
||
|
||
### Request Types and Where Types Live
|
||
Define all request and response-related types in the feature’s `utils/types.ts` file. **Do not define types or interfaces in `.svelte.ts` files** (e.g. `api/*.svelte.ts` or `store/*.svelte.ts`). Infer types from the API: request type from the RPC call, then success/data from the response type.
|
||
|
||
```typescript
|
||
// apps/dashboard/src/lib/features/org/utils/types.ts
|
||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||
|
||
export type GetTeamRequest = typeof classroomio.organization.team.$get;
|
||
export type CreateOrgRequest = typeof classroomio.organization.$post;
|
||
export type UpdateOrgRequest = typeof classroomio.organization.$put;
|
||
|
||
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
|
||
|
||
export type GetTeamSuccess = Extract<InferResponseType<GetTeamRequest>, { success: true }>;
|
||
export type OrganizationTeamMembers = GetTeamSuccess['data'];
|
||
```
|
||
|
||
### Type Inference Rules
|
||
|
||
**Never import types from `@cio/db/queries` into dashboard code.**
|
||
|
||
**Never define types in `.svelte.ts` files.** Put them in `{domain}/utils/types.ts` and infer from API return types.
|
||
|
||
Pattern:
|
||
1. Define request type: `typeof classroomio.{route}.$method`
|
||
2. Infer response type: `InferResponseType<RequestType>`
|
||
3. Extract success response: `Extract<ResponseType, { success: true }>`
|
||
4. Extract data type: `SuccessType['data']`
|
||
|
||
For response shapes that need a small normalization (e.g. API returns `JSONValue`, app needs `Record<string, number>`), derive a normalized type in `types.ts` from the success `data` type (e.g. `Omit<Data, 'roleMapping'> & { roleMapping: Record<string, number> }`).
|
||
|
||
**Do not export custom types from `apps/api/src/rpc-types.ts`.** Only re-export Hono client types (e.g. `InferRequestType`, `InferResponseType`) and the typed client (`Client`, `hcWithType`). All feature-specific types belong in the dashboard's feature `utils/types.ts`, inferred from the API.
|
||
|
||
### API Class Pattern
|
||
API classes handle all logic; components should be thin.
|
||
|
||
### Translations (UI copy)
|
||
|
||
- No hardcoded user-facing text in components.
|
||
- Strings live in `apps/dashboard/src/lib/utils/translations/en.json`.
|
||
- Use `$t('key.path')` in markup and `t('key.path')` in script.
|
||
|
||
Example:
|
||
```svelte
|
||
<script lang="ts">
|
||
import { t } from '$lib/utils/functions/translations';
|
||
</script>
|
||
|
||
<h1>{$t('course.navItem.lessons.heading_v2')}</h1>
|
||
```
|
||
|
||
Snackbar:
|
||
- Use translation keys directly (e.g. `snackbar.success('snackbar.profile.updated')`).
|
||
- Only use `t(...)` when combining keys or building dynamic strings.
|
||
|
||
Outside Svelte components:
|
||
- Use `t.get('key.path')` inside plain functions or utilities.
|
||
|
||
### Utility Functions Pattern
|
||
|
||
- Extract reusable transformations to `apps/dashboard/src/lib/features/{domain}/utils/{feature}-utils.ts`.
|
||
- Keep functions pure and accept all required data as parameters.
|
||
|
||
### Svelte store binding
|
||
|
||
You can bind form controls directly to a writable store’s properties. Svelte compiles `bind:value={$store.someProp}` so that reads come from the store and writes go back through the store (via `set`/`update`). Prefer this over duplicating state in the component and syncing with `$effect` (which can cause infinite loops or redundant updates).
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { inviteSettingsStore } from './store';
|
||
</script>
|
||
|
||
<InputField bind:value={$inviteSettingsStore.customExpiresAt} />
|
||
<Select.Root type="single" bind:value={$inviteSettingsStore.preset}>
|
||
```
|
||
|
||
### Reactive built-in collections
|
||
|
||
In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `SvelteSet` and `SvelteMap` from `svelte/reactivity` instead. They are already reactive on their own — do **not** wrap them in `$state()`.
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { SvelteSet } from 'svelte/reactivity';
|
||
|
||
let selectedIds = new SvelteSet<string>();
|
||
|
||
// Mutations are reactive — no clone-and-reassign needed
|
||
selectedIds.add('abc');
|
||
selectedIds.delete('abc');
|
||
selectedIds.clear();
|
||
</script>
|
||
```
|
||
|
||
### Server-Side API Calls
|
||
|
||
Use `.server.ts` files for server-side code to isolate API keys.
|
||
|
||
### UI Components
|
||
|
||
- Add new UI components under `packages/ui/src` following existing folder patterns.
|
||
- Document component usage and props in `packages/ui/README.md`.
|
||
- Add example usages and variants in Storybook stories under `packages/storybook/src`.
|
||
- See `packages/ui/README.md` and `packages/storybook/README.md` for full guidance.
|
||
- For dashboard forms, prefer `@cio/ui/custom/*-field` wrappers (for example `InputField`, `TextareaField`, `CheckboxField`) so label/error/spacing behavior stays consistent.
|
||
- Use base primitives (`@cio/ui/base/input`, `@cio/ui/base/textarea`, `@cio/ui/base/checkbox`, `@cio/ui/base/label`) only when creating/updating reusable UI components or when no custom field wrapper exists.
|
||
- In app-level form UIs, do not introduce native form controls (`<input>`, `<textarea>`, `<label>`) when equivalent `packages/ui` components exist.
|
||
- **Theme color classes:** Classes that use colors from `packages/ui/src/index.css` (e.g. `text-muted-foreground`, `text-primary`) must be prefixed with `ui:` in dashboard code so they resolve against the UI theme (e.g. `ui:text-muted-foreground`, `ui:text-primary`). Only color-related utilities need the prefix; layout/sizing classes like `rounded`, `border`, `p-4` stay unprefixed (Tailwind defaults).
|
||
|
||
## Best Practices Summary
|
||
|
||
### ✅ DO
|
||
- Use `AppError` with error codes and status codes
|
||
- Handle transactions in services
|
||
- Use `handleError` utility in route catch blocks
|
||
- Validate with Zod on both client and server
|
||
- Define request types in `{domain}/utils/types.ts` for reusability
|
||
- Use `this.execute<RequestType>()` in API classes (never call RPC directly)
|
||
- Keep components thin - only render UI and call API methods
|
||
- Use `.server.ts` for server-side API calls with API keys
|
||
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
|
||
- Put all user-facing copy in translations and reference by key
|
||
- Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`)
|
||
|
||
### ❌ DON'T
|
||
- Put business logic in routes or queries
|
||
- Include validation in queries (belongs in routes)
|
||
- Handle HTTP concerns in services
|
||
- Skip client-side validation
|
||
- Let components handle validation, navigation, or snackbar
|
||
- Include API key logic in `.svelte.ts` files
|
||
- **Define types or interfaces in `.svelte.ts` files** — use `{domain}/utils/types.ts` and infer from API types
|
||
- Call RPC client directly in server files (use `.server.ts` classes)
|
||
- Add unnecessary null checks for `user` when `authMiddleware` is present
|
||
- Write plain English strings in components
|
||
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
|
||
- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive
|
||
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
|
||
|
||
## Checklist for New Routes
|
||
|
||
- [ ] **Validation**: Schema in `packages/utils/src/validation/{entity}/`
|
||
- [ ] **Query**: Pure functions in `packages/db/src/queries/{domain}/`
|
||
- [ ] **Service**: Business logic in `apps/api/src/services/`
|
||
- [ ] **Route**: Handler in `apps/api/src/routes/{domain}/` (each route returns a single type)
|
||
- [ ] **Registration**: Export and register in `app.ts`
|
||
- [ ] **Build**: Run `pnpm --filter @cio/api build`
|
||
- [ ] **Frontend Types**: Request types in `apps/dashboard/src/lib/features/{domain}/utils/types.ts` (infer from API; do not import from `rpc-types`)
|
||
- [ ] **Frontend API**: API class in `apps/dashboard/src/lib/features/{domain}/api/`
|
||
|
||
## Common Patterns
|
||
|
||
### Query error logging (`packages/db/src/queries`)
|
||
In every `catch` block, log the original error with the function name so logs are traceable:
|
||
```typescript
|
||
} catch (error) {
|
||
console.error('functionName error:', error);
|
||
throw new Error(`Failed to ...`);
|
||
}
|
||
```
|
||
|
||
### Transactions
|
||
```typescript
|
||
const result = await db.transaction(async (tx) => {
|
||
const org = await createOrganization({ name, siteName });
|
||
const member = await createOrganizationMember({ orgId: org.id, userId });
|
||
return { org, member };
|
||
});
|
||
```
|
||
|
||
### Pagination
|
||
```typescript
|
||
const paginationSchema = 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')
|
||
});
|
||
|
||
return c.json({
|
||
success: true,
|
||
data: items,
|
||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
||
});
|
||
```
|
||
|
||
## Refactoring Components to Features/UI
|
||
|
||
When moving from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib/features/ui/`:
|
||
|
||
1. **Single-file**: `components/CodeSnippet/index.svelte` → `features/ui/code-snippet.svelte`
|
||
2. **Multi-file**: `components/Confetti/` → `features/ui/confetti/confetti.svelte`
|
||
3. **Update exports** in `features/ui/index.ts`
|
||
4. **Update imports**: `import { Component } from '$features/ui';`
|
||
5. **Delete old files** and verify no remaining references
|