- BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
const coerceAbv = z.preprocess((val) => {
|
|
if (val === null || val === undefined || val === '') return null;
|
|
const n = Number(val);
|
|
if (isNaN(n)) return null;
|
|
return n;
|
|
}, z.number().min(0).max(100).nullish());
|
|
|
|
const coerceAge = z.preprocess((val) => {
|
|
if (val === null || val === undefined || val === '') return null;
|
|
const n = Number(val);
|
|
if (isNaN(n)) return null;
|
|
// If it looks like a year (e.g. 19xx or 20xx), it's probably not the age
|
|
if (n > 1900 && n < 2100) return null;
|
|
return n;
|
|
}, z.number().min(0).max(200).nullish());
|
|
|
|
export const BottleMetadataSchema = z.object({
|
|
name: z.string().trim().min(1).max(255).nullish(),
|
|
distillery: z.string().trim().max(255).nullish(),
|
|
bottler: z.string().trim().max(255).nullish(),
|
|
series: z.string().trim().max(255).nullish(),
|
|
category: z.string().trim().max(100).nullish(),
|
|
abv: coerceAbv,
|
|
age: coerceAge,
|
|
vintage: z.string().trim().max(50).nullish(),
|
|
bottleCode: z.string().trim().max(100).nullish(),
|
|
whiskybaseId: z.string().trim().max(50).nullish(),
|
|
distilled_at: z.string().trim().max(50).nullish(),
|
|
bottled_at: z.string().trim().max(50).nullish(),
|
|
batch_info: z.string().trim().max(255).nullish(),
|
|
cask_type: z.string().trim().max(255).nullish(),
|
|
is_whisky: z.boolean().default(true),
|
|
confidence: z.number().min(0).max(100).default(100),
|
|
confidence_scores: z.record(z.string(), z.number().min(0).max(100)).nullish(),
|
|
purchase_price: z.number().min(0).nullish(),
|
|
status: z.enum(['sealed', 'open', 'sampled', 'empty']).default('sealed').nullish(),
|
|
suggested_tags: z.array(z.string().trim().max(100)).nullish(),
|
|
suggested_custom_tags: z.array(z.string().trim().max(100)).nullish(),
|
|
});
|
|
|
|
export type BottleMetadata = z.infer<typeof BottleMetadataSchema>;
|
|
|
|
export const TastingNoteSchema = z.object({
|
|
bottle_id: z.string().uuid(),
|
|
session_id: z.string().uuid().optional(),
|
|
rating: z.number().min(0).max(100),
|
|
nose_notes: z.string().trim().max(2000).optional(),
|
|
palate_notes: z.string().trim().max(2000).optional(),
|
|
finish_notes: z.string().trim().max(2000).optional(),
|
|
is_sample: z.boolean().optional().default(false),
|
|
buddy_ids: z.array(z.string().uuid()).optional(),
|
|
tag_ids: z.array(z.string().uuid()).optional(),
|
|
tasted_at: z.string().datetime().optional(),
|
|
blind_label: z.string().trim().max(100).nullish(),
|
|
guess_abv: z.number().min(0).max(100).nullish(),
|
|
guess_age: z.number().min(0).max(200).nullish(),
|
|
guess_region: z.string().trim().max(100).nullish(),
|
|
guess_points: z.number().min(0).nullish(),
|
|
flavor_profile: z.object({
|
|
smoky: z.number().min(0).max(100),
|
|
fruity: z.number().min(0).max(100),
|
|
spicy: z.number().min(0).max(100),
|
|
sweet: z.number().min(0).max(100),
|
|
floral: z.number().min(0).max(100),
|
|
}).optional(),
|
|
});
|
|
|
|
export type TastingNoteData = z.infer<typeof TastingNoteSchema>;
|
|
|
|
export const UpdateBottleSchema = z.object({
|
|
name: z.string().trim().min(1).max(255).nullish(),
|
|
distillery: z.string().trim().max(255).nullish(),
|
|
category: z.string().trim().max(100).nullish(),
|
|
abv: coerceAbv,
|
|
age: coerceAge,
|
|
whiskybase_id: z.string().trim().max(50).nullish(),
|
|
purchase_price: z.number().min(0).nullish(),
|
|
distilled_at: z.string().trim().max(50).nullish(),
|
|
bottled_at: z.string().trim().max(50).nullish(),
|
|
batch_info: z.string().trim().max(255).nullish(),
|
|
cask_type: z.string().trim().max(255).nullish(),
|
|
status: z.enum(['sealed', 'open', 'sampled', 'empty']).nullish(),
|
|
});
|
|
|
|
export type UpdateBottleData = z.infer<typeof UpdateBottleSchema>;
|
|
|
|
export const TagSchema = z.object({
|
|
name: z.string().trim().min(1).max(50),
|
|
category: z.enum(['nose', 'taste', 'finish', 'texture']),
|
|
});
|
|
|
|
export type TagData = z.infer<typeof TagSchema>;
|
|
|
|
export const AdminCreditUpdateSchema = z.object({
|
|
userId: z.string().uuid(),
|
|
newBalance: z.number().min(0).max(1000000),
|
|
reason: z.string().trim().min(1).max(255),
|
|
});
|
|
|
|
export type AdminCreditUpdateData = z.infer<typeof AdminCreditUpdateSchema>;
|
|
|
|
export const AdminSettingsSchema = z.object({
|
|
userId: z.string().uuid(),
|
|
dailyLimit: z.number().min(0).max(10000).nullable(),
|
|
googleSearchCost: z.number().min(0).max(1000).optional(),
|
|
geminiAiCost: z.number().min(0).max(1000).optional(),
|
|
});
|
|
|
|
export type AdminSettingsData = z.infer<typeof AdminSettingsSchema>;
|
|
|
|
export const DiscoveryDataSchema = z.object({
|
|
name: z.string().trim().min(1).max(255),
|
|
distillery: z.string().trim().max(255).nullish(),
|
|
abv: coerceAbv,
|
|
age: coerceAge,
|
|
distilled_at: z.string().trim().max(50).nullish(),
|
|
bottled_at: z.string().trim().max(50).nullish(),
|
|
batch_info: z.string().trim().max(255).nullish(),
|
|
cask_type: z.string().trim().max(255).nullish(),
|
|
});
|
|
|
|
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
|
|
|
export const BuddySchema = z.object({
|
|
name: z.string().trim().min(1).max(100),
|
|
});
|
|
|
|
export type BuddyData = z.infer<typeof BuddySchema>;
|
|
|
|
export interface AnalysisResponse {
|
|
success: boolean;
|
|
data?: BottleMetadata;
|
|
error?: string;
|
|
isAiError?: boolean;
|
|
imageHash?: string;
|
|
perf?: {
|
|
// Legacy fields (kept for backward compatibility)
|
|
apiDuration?: number;
|
|
parseDuration?: number;
|
|
|
|
// Detailed metrics
|
|
imagePrep?: number;
|
|
cacheCheck?: number;
|
|
encoding?: number;
|
|
modelInit?: number;
|
|
apiCall?: number;
|
|
parsing?: number;
|
|
validation?: number;
|
|
dbOps?: number;
|
|
uploadSize: number;
|
|
total?: number;
|
|
cacheHit?: boolean;
|
|
};
|
|
raw?: any;
|
|
}
|