a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

Add Lexicon schemas and validation utilities

+419
+16
src/api/agent.ts
···
··· 1 + import { Agent } from '@atproto/api' 2 + import { getClient } from '@/auth' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 + 5 + /** 6 + * Create AT Protocol agent from OAuth session 7 + * Returns both the agent and the session (which contains the DID) 8 + */ 9 + export async function createAgent(): Promise<{ agent: Agent; session: OAuthSession } | null> { 10 + const client = await getClient() 11 + const result = await client.init() 12 + if (!result?.session) return null 13 + 14 + const agent = new Agent(result.session) 15 + return { agent, session: result.session } 16 + }
+96
src/api/fragrances.ts
···
··· 1 + import type { Agent } from '@atproto/api' 2 + import type { Fragrance, AtUri } from '@/types/lexicon-types' 3 + 4 + const COLLECTION = 'social.drydown.fragrance' 5 + 6 + /** 7 + * Create a new fragrance record 8 + */ 9 + export async function createFragrance( 10 + agent: Agent, 11 + did: string, 12 + data: { 13 + name: string 14 + house: AtUri 15 + year?: number 16 + } 17 + ): Promise<{ uri: AtUri; fragrance: Fragrance }> { 18 + const record: Omit<Fragrance, 'uri'> = { 19 + name: data.name.trim(), 20 + house: data.house, 21 + year: data.year, 22 + createdAt: new Date().toISOString() 23 + } 24 + 25 + const response = await agent.com.atproto.repo.createRecord({ 26 + repo: did, 27 + collection: COLLECTION, 28 + record 29 + }) 30 + 31 + return { uri: response.data.uri, fragrance: { ...record, uri: response.data.uri } } 32 + } 33 + 34 + /** 35 + * List all fragrances for current user 36 + */ 37 + export async function listFragrances(agent: Agent, did: string): Promise<Fragrance[]> { 38 + const response = await agent.com.atproto.repo.listRecords({ 39 + repo: did, 40 + collection: COLLECTION, 41 + limit: 100 42 + }) 43 + 44 + return response.data.records.map((r: any) => ({ 45 + uri: r.uri, 46 + ...(r.value as Omit<Fragrance, 'uri'>) 47 + })) 48 + } 49 + 50 + /** 51 + * Update fragrance (name or year) 52 + */ 53 + export async function updateFragrance( 54 + agent: Agent, 55 + did: string, 56 + uri: AtUri, 57 + updates: Partial<Pick<Fragrance, 'name' | 'year'>> 58 + ): Promise<void> { 59 + const rkey = uri.split('/').pop()! 60 + 61 + const getResponse = await agent.com.atproto.repo.getRecord({ 62 + repo: did, 63 + collection: COLLECTION, 64 + rkey 65 + }) 66 + 67 + const updated = { 68 + ...getResponse.data.value, 69 + ...updates, 70 + updatedAt: new Date().toISOString() 71 + } 72 + 73 + await agent.com.atproto.repo.putRecord({ 74 + repo: did, 75 + collection: COLLECTION, 76 + rkey, 77 + record: updated 78 + }) 79 + } 80 + 81 + /** 82 + * Delete fragrance 83 + */ 84 + export async function deleteFragrance( 85 + agent: Agent, 86 + did: string, 87 + uri: AtUri 88 + ): Promise<void> { 89 + const rkey = uri.split('/').pop()! 90 + 91 + await agent.com.atproto.repo.deleteRecord({ 92 + repo: did, 93 + collection: COLLECTION, 94 + rkey 95 + }) 96 + }
+90
src/api/houses.ts
···
··· 1 + import type { Agent } from '@atproto/api' 2 + import type { House, AtUri } from '@/types/lexicon-types' 3 + 4 + const COLLECTION = 'social.drydown.house' 5 + 6 + /** 7 + * Create a new house record 8 + */ 9 + export async function createHouse( 10 + agent: Agent, 11 + did: string, 12 + name: string 13 + ): Promise<{ uri: AtUri; house: House }> { 14 + const record: Omit<House, 'uri'> = { 15 + name: name.trim(), 16 + createdAt: new Date().toISOString() 17 + } 18 + 19 + const response = await agent.com.atproto.repo.createRecord({ 20 + repo: did, 21 + collection: COLLECTION, 22 + record 23 + }) 24 + 25 + return { uri: response.data.uri, house: { ...record, uri: response.data.uri } } 26 + } 27 + 28 + /** 29 + * List all houses for current user 30 + */ 31 + export async function listHouses(agent: Agent, did: string): Promise<House[]> { 32 + const response = await agent.com.atproto.repo.listRecords({ 33 + repo: did, 34 + collection: COLLECTION, 35 + limit: 100 36 + }) 37 + 38 + return response.data.records.map((r: any) => ({ 39 + uri: r.uri, 40 + ...(r.value as Omit<House, 'uri'>) 41 + })) 42 + } 43 + 44 + /** 45 + * Update house (e.g., correct name spelling) 46 + */ 47 + export async function updateHouse( 48 + agent: Agent, 49 + did: string, 50 + uri: AtUri, 51 + updates: Partial<Pick<House, 'name'>> 52 + ): Promise<void> { 53 + const rkey = uri.split('/').pop()! 54 + 55 + const getResponse = await agent.com.atproto.repo.getRecord({ 56 + repo: did, 57 + collection: COLLECTION, 58 + rkey 59 + }) 60 + 61 + const updated = { 62 + ...getResponse.data.value, 63 + ...updates, 64 + updatedAt: new Date().toISOString() 65 + } 66 + 67 + await agent.com.atproto.repo.putRecord({ 68 + repo: did, 69 + collection: COLLECTION, 70 + rkey, 71 + record: updated 72 + }) 73 + } 74 + 75 + /** 76 + * Delete house (only if no fragrances reference it - validated client-side first) 77 + */ 78 + export async function deleteHouse( 79 + agent: Agent, 80 + did: string, 81 + uri: AtUri 82 + ): Promise<void> { 83 + const rkey = uri.split('/').pop()! 84 + 85 + await agent.com.atproto.repo.deleteRecord({ 86 + repo: did, 87 + collection: COLLECTION, 88 + rkey 89 + }) 90 + }
+44
src/lexicons/social.drydown.fragrance.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.drydown.fragrance", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "An individual fragrance with house reference", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "house", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 200, 17 + "description": "Fragrance name" 18 + }, 19 + "house": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "AT URI reference to house record (at://did/social.drydown.house/rkey)" 23 + }, 24 + "year": { 25 + "type": "integer", 26 + "minimum": 1000, 27 + "maximum": 2100, 28 + "description": "Year of release (optional)" 29 + }, 30 + "createdAt": { 31 + "type": "string", 32 + "format": "datetime", 33 + "description": "Timestamp when fragrance was created" 34 + }, 35 + "updatedAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of last update" 39 + } 40 + } 41 + } 42 + } 43 + } 44 + }
+33
src/lexicons/social.drydown.house.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.drydown.house", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A fragrance house or brand", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 100, 17 + "description": "House/brand name (must be unique per user)" 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when house was created" 23 + }, 24 + "updatedAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp of last update (for name corrections)" 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+26
src/types/lexicon-types.ts
···
··· 1 + /** 2 + * AT Protocol URI format: at://did/collection/rkey 3 + */ 4 + export type AtUri = string 5 + 6 + /** 7 + * House/brand record (social.drydown.house) 8 + */ 9 + export interface House { 10 + uri?: AtUri // AT Protocol record URI (assigned after creation) 11 + name: string // 1-100 chars, required, unique per user 12 + createdAt: string // ISO 8601 13 + updatedAt?: string // ISO 8601 14 + } 15 + 16 + /** 17 + * Fragrance record (social.drydown.fragrance) 18 + */ 19 + export interface Fragrance { 20 + uri?: AtUri // AT Protocol record URI 21 + name: string // 1-200 chars, required 22 + house: AtUri // Reference to house record (required) 23 + year?: number // 1000-2100, optional 24 + createdAt: string // ISO 8601 25 + updatedAt?: string // ISO 8601 26 + }
+21
src/validation/cascade.ts
···
··· 1 + import type { Fragrance, AtUri } from '@/types/lexicon-types' 2 + 3 + /** 4 + * Check if house can be deleted (no fragrances reference it) 5 + */ 6 + export function canDeleteHouse( 7 + houseUri: AtUri, 8 + fragrances: Fragrance[] 9 + ): { canDelete: boolean; reason?: string; count?: number } { 10 + const referencingFragrances = fragrances.filter(f => f.house === houseUri) 11 + 12 + if (referencingFragrances.length > 0) { 13 + return { 14 + canDelete: false, 15 + reason: `${referencingFragrances.length} fragrance(s) reference this house`, 16 + count: referencingFragrances.length 17 + } 18 + } 19 + 20 + return { canDelete: true } 21 + }
+20
src/validation/uniqueness.ts
···
··· 1 + import type { House, AtUri } from '@/types/lexicon-types' 2 + 3 + /** 4 + * Check if house name is unique (case-insensitive) 5 + */ 6 + export function isHouseNameUnique( 7 + name: string, 8 + existingHouses: House[], 9 + excludeUri?: AtUri 10 + ): boolean { 11 + const normalized = name.trim().toLowerCase() 12 + return !existingHouses.some(h => 13 + h.uri !== excludeUri && 14 + h.name.toLowerCase() === normalized 15 + ) 16 + } 17 + 18 + // NOTE: Fragrances do NOT need to be unique 19 + // The same house can have multiple fragrances with the same name but different years 20 + // Example: "Creed Aventus 2004" and "Creed Aventus 2024" are both valid
+65
src/validation/validators.ts
···
··· 1 + import type { House, Fragrance } from '@/types/lexicon-types' 2 + import { isHouseNameUnique } from './uniqueness' 3 + 4 + export interface ValidationError { 5 + field: string 6 + message: string 7 + } 8 + 9 + export interface ValidationResult { 10 + valid: boolean 11 + errors: ValidationError[] 12 + } 13 + 14 + export function validateHouse( 15 + house: House, 16 + existingHouses: House[] 17 + ): ValidationResult { 18 + const errors: ValidationError[] = [] 19 + 20 + if (!house.name || house.name.trim().length === 0) { 21 + errors.push({ field: 'name', message: 'House name is required' }) 22 + } 23 + 24 + if (house.name && house.name.length > 100) { 25 + errors.push({ field: 'name', message: 'House name must be 100 characters or less' }) 26 + } 27 + 28 + if (house.name && !isHouseNameUnique(house.name, existingHouses, house.uri)) { 29 + errors.push({ field: 'name', message: 'A house with this name already exists' }) 30 + } 31 + 32 + return { valid: errors.length === 0, errors } 33 + } 34 + 35 + export function validateFragrance( 36 + fragrance: Fragrance, 37 + houses: House[] 38 + ): ValidationResult { 39 + const errors: ValidationError[] = [] 40 + 41 + if (!fragrance.name || fragrance.name.trim().length === 0) { 42 + errors.push({ field: 'name', message: 'Fragrance name is required' }) 43 + } 44 + 45 + if (fragrance.name && fragrance.name.length > 200) { 46 + errors.push({ field: 'name', message: 'Fragrance name must be 200 characters or less' }) 47 + } 48 + 49 + if (!fragrance.house) { 50 + errors.push({ field: 'house', message: 'House is required' }) 51 + } 52 + 53 + if (fragrance.house && !houses.some(h => h.uri === fragrance.house)) { 54 + errors.push({ field: 'house', message: 'Selected house does not exist' }) 55 + } 56 + 57 + // NOTE: No uniqueness check for (house, name) pairs 58 + // Multiple fragrances can have the same name under the same house (different years/batches) 59 + 60 + if (fragrance.year !== undefined && (fragrance.year < 1000 || fragrance.year > 2100)) { 61 + errors.push({ field: 'year', message: 'Year must be between 1000 and 2100' }) 62 + } 63 + 64 + return { valid: errors.length === 0, errors } 65 + }
+1
tsconfig.app.json
··· 8 "types": ["vite/client"], 9 "skipLibCheck": true, 10 "paths": { 11 "react": ["./node_modules/preact/compat/"], 12 "react-dom": ["./node_modules/preact/compat/"] 13 },
··· 8 "types": ["vite/client"], 9 "skipLibCheck": true, 10 "paths": { 11 + "@/*": ["./src/*"], 12 "react": ["./node_modules/preact/compat/"], 13 "react-dom": ["./node_modules/preact/compat/"] 14 },
+7
vite.config.ts
··· 1 import { defineConfig } from 'vite' 2 import preact from '@preact/preset-vite' 3 export default defineConfig({ 4 plugins: [preact()], 5 server: { 6 host: '127.0.0.1', 7 port: 5173,
··· 1 import { defineConfig } from 'vite' 2 import preact from '@preact/preset-vite' 3 + import path from 'path' 4 + 5 export default defineConfig({ 6 plugins: [preact()], 7 + resolve: { 8 + alias: { 9 + '@': path.resolve(__dirname, './src') 10 + } 11 + }, 12 server: { 13 host: '127.0.0.1', 14 port: 5173,