* feat(org): add dynamic open graph images for school sites Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png. Paying cloud orgs include their landing-page tagline; free plans show a Powered by ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image. Co-authored-by: Rotimi Best <[email protected]> * feat(org): enable custom favicon upload and org-site rendering Allow org admins to upload a favicon from domain settings, persist it via PUT /organization, and render it on public org sites with logo fallback. Co-authored-by: Rotimi Best <[email protected]> * fix(org): scope custom favicon to public org-site routes only Co-authored-by: Rotimi Best <[email protected]> * refactor(api): inline org-site OG rendering, remove @cio/og-images package Move template and render helpers into apps/api/src/utils/org-site-og.ts. Certificates stay in a shared package because they support multiple templates; org OG is a single API-only concern. Co-authored-by: Rotimi Best <[email protected]> * chore: remove packages/og-images directory Co-authored-by: Rotimi Best <[email protected]> * refactor(api): mount org OG image under /org-site/og Align with existing org-site public routes (/org-site/course) instead of a top-level /org prefix. Co-authored-by: Rotimi Best <[email protected]> * refactor(plans): consolidate isOrgOnFreePlan into options-based utility Move orgId guard and self-hosted/plan checks into @cio/utils/plans. Dashboard and API callers pass plans, isSelfHosted, and optional orgId directly; remove the dashboard store wrapper. Co-authored-by: Rotimi Best <[email protected]> * fix(org): require orgId for free-plan check and translate favicon strings Treat missing orgId as not on free plan so nav gating matches prior behavior when currentOrg is unset. Pass organization.id from OG service. Add favicon settings translations for all non-English locales. Co-authored-by: Rotimi Best <[email protected]> * refactor(dashboard): move meta tag helpers to metaTags.server.ts Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic private env imports cannot leak into universal or client bundles. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
Validation Schemas
Shared validation schemas for ClassroomIO workspace using Zod with convention-based translations.
Structure
Each entity has its own folder with entity-specific schemas:
validation/
├── course/
│ ├── course.ts # Course validation schemas
│ └── index.ts # Re-exports
├── mail/
│ ├── mail.ts # Email validation schemas
│ └── index.ts # Re-exports
├── constants.ts # Shared constants
├── index.ts # Main entry point
├── README.md # This file
└── USAGE.md # Detailed usage guide
Quick Start
Import Schemas
// Import from main module
import { ZCourseClone, TCourseClone } from '@cio/utils/validation';
// Import from entity module
import { ZEmailData } from '@cio/utils/validation/mail';
Backend Usage (API)
Schemas are used without custom error messages - just validation rules:
import { ZCourseClone } from '@cio/utils/validation';
import { zValidator } from '@hono/zod-validator';
export const cloneRouter = new Hono().post('/', zValidator('json', ZCourseClone), async (c) => {
const data = c.req.valid('json'); // Fully typed!
// ... handle validated data
});
Frontend Usage (Dashboard)
Use the error mapper to get translated messages:
import { ZEmailData } from '@cio/utils/validation/mail';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
let formData = { to: '', subject: '', content: '' };
let errors: Record<string, string> = {};
function handleSubmit() {
const result = ZEmailData.safeParse(formData);
if (!result.success) {
// Automatically maps to translations using convention
errors = mapZodErrorsToTranslations(result.error);
return;
}
// Use validated data
doSomething(result.data);
}
Translation Convention
We use convention-based translation keys to avoid hardcoding messages in schemas.
How It Works
Schemas are clean with no custom error messages:
// ✅ Clean schema (in @cio/utils)
export const ZEmailData = z.object({
to: z.string().email(), // No message
subject: z.string().min(1), // No message
content: z.string().min(10) // No message
});
Frontend maps errors to translations using this convention:
validations.{fieldName}.{errorCode}
Example Translation Keys
| Field | Validation | Error Code | Translation Key |
|---|---|---|---|
to |
.email() |
invalid_string |
validations.to.invalid_email |
subject |
.min(1) |
too_small |
validations.subject.too_small |
username |
.min(3) |
too_small |
validations.username.too_small |
username |
.max(20) |
too_big |
validations.username.too_big |
Translation File Example
{
"validations": {
"to": {
"invalid_email": "Invalid email address",
"invalid_type": "Email is required"
},
"subject": {
"too_small": "Subject is required"
},
"username": {
"too_small": "Username must be at least 3 characters",
"too_big": "Username cannot exceed 20 characters"
},
"generic": {
"required": "This field is required",
"invalid_email": "Invalid email address",
"too_small_string": "Must be at least {min} characters"
}
}
}
Benefits
✅ Single Source of Truth - Schema defined once, used everywhere
✅ No Duplication - Don't reimplement schemas in frontend
✅ Flexible - Add specific translations or use generic fallbacks
✅ Multilingual - Easy to translate without touching schemas
✅ Maintainable - Change schema once, translations adapt automatically
Common Error Codes
| Zod Error | When It Occurs |
|---|---|
too_small |
String too short, number too small, array too few items |
too_big |
String too long, number too large, array too many items |
invalid_email |
Invalid email format (from .email()) |
invalid_url |
Invalid URL format (from .url()) |
invalid_type |
Wrong type (e.g., undefined when string expected) |
invalid_enum_value |
Value not in allowed options |
Naming Convention
- Schemas: Prefixed with
Z(e.g.,ZCourseClone) - Types: Prefixed with
T(e.g.,TCourseClone) - Translation Keys: Follow pattern
validations.{field}.{errorCode}
Adding New Validations
1. Create Schema in Utils Package
// packages/utils/src/validation/lesson/lesson.ts
import * as z from 'zod';
/**
* Lesson Create Validation Schema
* Used for creating new lessons
*
* Translation keys:
* - validations.title.too_small
* - validations.duration.too_small
*/
export const ZLessonCreate = z.object({
title: z.string().min(1), // Clean - no message
duration: z.number().min(1) // Clean - no message
});
export type TLessonCreate = z.infer<typeof ZLessonCreate>;
// validation/lesson/index.ts
export * from './lesson';
2. Export from Main Module
// packages/utils/src/validation/index.ts
export * from './course';
export * from './mail';
export * from './lesson'; // Add this
3. Use in API
// apps/api/src/routes/lesson/create.ts
import { ZLessonCreate } from '@cio/utils/validation/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post('/', zValidator('json', ZLessonCreate), async (c) => {
const data = c.req.valid('json');
// ... create lesson
});
4. Add Translations in Frontend
// apps/dashboard/src/lib/utils/translations/en.json
{
"validations": {
"title": {
"too_small": "Title is required",
"too_big": "Title is too long"
},
"duration": {
"too_small": "Duration must be at least 1 minute"
}
}
}
5. Use in Frontend Form
// apps/dashboard/src/lib/components/LessonForm.svelte
import { ZLessonCreate } from '@cio/utils/validation/lesson';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
let formData = { title: '', duration: 0 };
let errors = {};
function handleSubmit() {
const result = ZLessonCreate.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error);
// errors.title = "Title is required" (translated)
return;
}
createLesson(result.data);
}
Best Practices
1. Keep Schemas Clean - No Error Messages
Schemas should contain only validation rules, no custom messages:
// ✅ Good - Clean schema
export const ZUserProfile = z.object({
email: z.string().email(),
username: z.string().min(3).max(20)
});
// ❌ Bad - Hardcoded messages
export const ZUserProfile = z.object({
email: z.string().email('Invalid email'), // Don't do this
username: z.string().min(3, 'Too short') // Don't do this
});
// ❌ Bad - Translated messages in schema
export const ZUserProfile = z.object({
email: z.string().email(t.get('validations.email')) // Don't do this
});
Why? Schemas are shared between backend (no translations) and frontend (with translations). Keep them clean and universal.
2. Always Export Types
export const ZCourseClone = z.object({
/* ... */
});
export type TCourseClone = z.infer<typeof ZCourseClone>;
3. Document Schemas with Translation Keys
Help frontend developers know which translation keys to add:
/**
* User Profile Validation Schema
* Used for updating user profile information
*
* Translation keys (convention-based):
* - validations.email.invalid_email
* - validations.username.too_small
* - validations.username.too_big
* - validations.bio.too_big
*/
export const ZUserProfile = z.object({
email: z.string().email(),
username: z.string().min(3).max(20),
bio: z.string().max(500).optional()
});
export type TUserProfile = z.infer<typeof ZUserProfile>;
4. Use Descriptive Field Names
Field names become part of translation keys, so use clear names:
// ✅ Good - Clear field names
z.object({
email: z.string().email(),
fullName: z.string().min(5),
phoneNumber: z.string().min(10)
});
// Translation keys: validations.email.*, validations.fullName.*, validations.phoneNumber.*
// ❌ Bad - Unclear field names
z.object({
e: z.string().email(),
fn: z.string().min(5),
ph: z.string().min(10)
});
// Translation keys: validations.e.*, validations.fn.*, validations.ph.* (unclear!)
5. Group Related Validations
Keep related schemas together in the same entity folder:
// validation/user/user.ts
export const ZUserCreate = z.object({
/* ... */
});
export const ZUserUpdate = z.object({
/* ... */
});
export const ZUserProfile = z.object({
/* ... */
});
6. Don't Duplicate Schemas
Never reimplement schemas in the frontend:
// ❌ Bad - Reimplementing in frontend
// apps/dashboard/src/schemas/user.ts
export const userSchema = z.object({
email: z.string().email(t.get('validations.email.invalid'))
// ... duplicating from @cio/utils
});
// ✅ Good - Import and use error mapper
import { ZUserProfile } from '@cio/utils/validation/user';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
const result = ZUserProfile.safeParse(data);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error);
}