diff --git a/src/lib/api.ts b/src/lib/api.ts index b92954d7..229ca200 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,11 +1,7 @@ -import { PUBLIC_API_BASE } from '$env/static/public' - -export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise +import type { FetchLike } from '$lib/api/core' +import { buildUrl, json } from '$lib/api/core' export async function getJson(path: string, fetchFn: FetchLike, init?: RequestInit): Promise { - const base = PUBLIC_API_BASE || '' - const url = path.startsWith('http') ? path : `${base}${path}` - const res = await fetchFn(url, { credentials: 'include', ...init }) - if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`) - return res.json() as Promise + const url = buildUrl(path) + return json(fetchFn, url, init) } diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts index 6f57c0da..7436ae75 100644 --- a/src/lib/api/core.ts +++ b/src/lib/api/core.ts @@ -3,39 +3,54 @@ import { PUBLIC_SIERO_API_URL } from '$env/static/public' export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise export type Dict = Record -const API = PUBLIC_SIERO_API_URL?.replace(/\/$/, '') ?? 'http://localhost:3000/api/v1' +// Compute a stable API base that always includes the versioned prefix. +function computeApiBase(): string { + const raw = (PUBLIC_SIERO_API_URL || 'http://localhost:3000') as string + const u = new URL(raw, raw.startsWith('http') ? undefined : 'http://localhost') + const origin = u.origin + const path = u.pathname.replace(/\/$/, '') + const hasVersion = /(\/api\/v1|\/v1)$/.test(path) + const basePath = hasVersion ? path : `${path}/api/v1` + return `${origin}${basePath}` +} + +export const API_BASE = computeApiBase() export function buildUrl(path: string, params?: Dict) { - const url = new URL(path.startsWith('http') ? path : `${API}${path}`, API) - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue - if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x))) - else url.searchParams.set(key, String(value)) - } - } - return url.toString() + const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`, API_BASE) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue + if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x))) + else url.searchParams.set(key, String(value)) + } + } + return url.toString() } export async function json(fetchFn: FetchLike, url: string, init?: RequestInit): Promise { - const res = await fetchFn(url, { - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, - ...init - }) + const res = await fetchFn(url, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + ...init + }) - if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`) - return res.json() as Promise + if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`) + return res.json() as Promise } export const get = (f: FetchLike, path: string, params?: Dict, init?: RequestInit) => - json(f, buildUrl(path, params), init) + json(f, buildUrl(path, params), init) -export const post = (f: FetchLike, path: string, body?: unknown, init?: RequestInit) => - json(f, path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...init }) +export const post = (f: FetchLike, path: string, body?: unknown, init?: RequestInit) => { + const extra = body !== undefined ? { body: JSON.stringify(body) } : {} + return json(f, buildUrl(path), { method: 'POST', ...extra, ...init }) +} -export const put = (f: FetchLike, path: string, body?: unknown, init?: RequestInit) => - json(f, path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...init }) +export const put = (f: FetchLike, path: string, body?: unknown, init?: RequestInit) => { + const extra = body !== undefined ? { body: JSON.stringify(body) } : {} + return json(f, buildUrl(path), { method: 'PUT', ...extra, ...init }) +} export const del = (f: FetchLike, path: string, init?: RequestInit) => - json(f, path, { method: 'DELETE', ...init }) + json(f, buildUrl(path), { method: 'DELETE', ...init }) diff --git a/src/lib/api/resources/parties.ts b/src/lib/api/resources/parties.ts index e69de29b..c43a56d9 100644 --- a/src/lib/api/resources/parties.ts +++ b/src/lib/api/resources/parties.ts @@ -0,0 +1,382 @@ +import { buildUrl, get, post, put, del, type FetchLike } from '$lib/api/core' +import { parseParty, type Party } from '$lib/api/schemas/party' +import { camelToSnake } from '$lib/api/schemas/transforms' +import { z } from 'zod' + +/** + * Party API resource functions + */ + +// Response schemas +// Note: The API returns snake_case; we validate with raw schemas and +// convert to camelCase via parseParty() at the edge. +const PartyResponseSchema = z.object({ + party: z.any() // We'll validate after extracting +}) + +const PartiesResponseSchema = z.object({ + parties: z.array(z.any()), + total: z.number().optional() +}) + +const ConflictResponseSchema = z.object({ + conflicts: z.array(z.string()), + incoming: z.string(), + position: z.number() +}) + +const PaginatedPartiesSchema = z.object({ + results: z.array(z.any()), + meta: z + .object({ + count: z.number().optional(), + total_pages: z.number().optional(), + per_page: z.number().optional() + }) + .optional() +}) + +// API functions +export async function getByShortcode(fetch: FetchLike, shortcode: string): Promise { + const url = buildUrl(`/parties/${encodeURIComponent(shortcode)}`) + const res = await fetch(url, { credentials: 'include' }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + // Extract the party object (API returns { party: {...} }) + const partyData = json?.party || json + + // Validate and transform snake_case to camelCase + const parsed = parseParty(partyData) + return parsed +} + +export async function create( + fetch: FetchLike, + payload: Partial, + headers?: Record +): Promise { + const body = camelToSnake(payload) + const res = await fetch(buildUrl('/parties'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const parsed = PartyResponseSchema.parse(json) + return parseParty(parsed.party) +} + +export async function list( + fetch: FetchLike, + params?: { page?: number } +): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> { + const page = params?.page && params.page > 0 ? params.page : 1 + const url = buildUrl('/parties', { page }) + const res = await fetch(url, { credentials: 'include' }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + // Controller returns { results: [...], meta: { count, total_pages, per_page } } + const parsed = PaginatedPartiesSchema.parse(json) + const items = (parsed.results || []).map(parseParty) + return { + items, + total: parsed.meta?.count, + totalPages: parsed.meta?.total_pages, + perPage: parsed.meta?.per_page, + page + } +} + +export async function favorites( + fetch: FetchLike, + params?: { page?: number } +): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> { + const page = params?.page && params.page > 0 ? params.page : 1 + const url = buildUrl('/parties/favorites', { page }) + const res = await fetch(url, { credentials: 'include' }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const parsed = PaginatedPartiesSchema.parse(json) + const items = (parsed.results || []).map(parseParty) + return { + items, + total: parsed.meta?.count, + totalPages: parsed.meta?.total_pages, + perPage: parsed.meta?.per_page, + page + } +} + +export async function update( + fetch: FetchLike, + id: string, + payload: Partial, + headers?: Record +): Promise { + const body = camelToSnake(payload) + const res = await fetch(buildUrl(`/parties/${id}`), { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + + // Handle conflict response + if ('conflicts' in json) { + const conflict = ConflictResponseSchema.parse(json) + throw { + type: 'conflict', + ...conflict + } + } + + const parsed = PartyResponseSchema.parse(json) + return parseParty(parsed.party) +} + +export async function remix( + fetch: FetchLike, + shortcode: string, + localId?: string, + headers?: Record +): Promise<{ party: Party; editKey?: string }> { + const body = localId ? { local_id: localId } : {} + const res = await fetch(buildUrl(`/parties/${encodeURIComponent(shortcode)}/remix`), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const parsed = PartyResponseSchema.parse(json) + + // Check for edit_key in response + const editKey = (json as any).edit_key + + return { + party: parseParty(parsed.party), + editKey + } +} + +export async function favorite( + fetch: FetchLike, + id: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl(`/parties/${id}/favorite`), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + } + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } +} + +export async function unfavorite( + fetch: FetchLike, + id: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl(`/parties/${id}/unfavorite`), { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + } + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } +} + +export async function deleteParty( + fetch: FetchLike, + id: string, + headers?: Record +): Promise { + const res = await fetch(buildUrl(`/parties/${id}`), { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + } + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } +} + +// Grid update functions +export async function updateWeaponGrid( + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record +): Promise { + const body = camelToSnake(payload) + const res = await fetch(buildUrl(`/parties/${partyId}/grid_weapons`), { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + + // Handle conflict response + if ('conflicts' in json) { + const conflict = ConflictResponseSchema.parse(json) + throw { + type: 'conflict', + ...conflict + } + } + + const parsed = PartyResponseSchema.parse(json) + return parseParty(parsed.party) +} + +export async function updateSummonGrid( + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record +): Promise { + const body = camelToSnake(payload) + const res = await fetch(buildUrl(`/parties/${partyId}/grid_summons`), { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + const parsed = PartyResponseSchema.parse(json) + return parseParty(parsed.party) +} + +export async function updateCharacterGrid( + fetch: FetchLike, + partyId: string, + payload: any, + headers?: Record +): Promise { + const body = camelToSnake(payload) + const res = await fetch(buildUrl(`/parties/${partyId}/grid_characters`), { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + const error = await parseError(res) + throw error + } + + const json = await res.json() + + // Handle conflict response + if ('conflicts' in json) { + const conflict = ConflictResponseSchema.parse(json) + throw { + type: 'conflict', + ...conflict + } + } + + const parsed = PartyResponseSchema.parse(json) + return parseParty(parsed.party) +} + +// Helper to parse API errors +async function parseError(res: Response): Promise { + let message = res.statusText || 'Request failed' + + try { + const data = await res.clone().json() + if (typeof data?.error === 'string') message = data.error + else if (typeof data?.message === 'string') message = data.message + else if (Array.isArray(data?.errors)) message = data.errors.join(', ') + } catch {} + + const error = new Error(message) as any + error.status = res.status + return error +} diff --git a/src/lib/api/resources/users.ts b/src/lib/api/resources/users.ts index 39cb7e2a..bcc96d4d 100644 --- a/src/lib/api/resources/users.ts +++ b/src/lib/api/resources/users.ts @@ -1,5 +1,5 @@ import type { FetchLike } from '../core' -import { get } from '../core' +import { get, buildUrl } from '../core' export interface UserInfoResponse { id: string @@ -19,3 +19,29 @@ export const users = { info: (f: FetchLike, username: string, init?: RequestInit) => get(f, `/users/info/${encodeURIComponent(username)}`, undefined, init) } + +export interface UserProfileResponse { + profile: UserInfoResponse & { parties?: any[] } + meta?: { count?: number; total_pages?: number; per_page?: number } +} + +export async function profile( + f: FetchLike, + username: string, + page?: number +): Promise<{ user: UserInfoResponse; items: any[]; page: number; total?: number; totalPages?: number; perPage?: number }> { + const qs = page && page > 1 ? { page } : undefined + const url = buildUrl(`/users/${encodeURIComponent(username)}`, qs as any) + const resp = await f(url, { credentials: 'include' }) + if (!resp.ok) throw new Error(resp.statusText || 'Failed to load profile') + const json = (await resp.json()) as UserProfileResponse + const items = Array.isArray(json.profile?.parties) ? json.profile.parties : [] + return { + user: json.profile as any, + items, + page: page || 1, + total: json.meta?.count, + totalPages: json.meta?.total_pages, + perPage: json.meta?.per_page + } +} diff --git a/src/lib/api/schemas/party.ts b/src/lib/api/schemas/party.ts new file mode 100644 index 00000000..fdf117ef --- /dev/null +++ b/src/lib/api/schemas/party.ts @@ -0,0 +1,539 @@ +import { z } from 'zod' +import { snakeToCamel } from './transforms' + +// Minimal camelCase validation to start small and safe +const MinimalCamelPartySchema = z + .object({ + id: z.string(), + shortcode: z.string(), + user: z + .object({ id: z.string().optional() }) + .nullish() + .optional(), + localId: z.string().nullish().optional() + }) + .passthrough() + +// Minimal TS types for UI (grid view) +export type LocalizedName = string | { en?: string | null; ja?: string | null } +export interface NamedObject { name?: LocalizedName | null; [k: string]: unknown } +export interface GridWeaponItemView { position: number; mainhand?: boolean | null; object?: NamedObject | null; [k: string]: unknown } +export interface GridSummonItemView { position: number; main?: boolean | null; friend?: boolean | null; quickSummon?: boolean | null; object?: NamedObject | null; [k: string]: unknown } +export interface GridCharacterItemView { position: number; perpetuity?: boolean | null; transcendenceStep?: number | null; object?: NamedObject | null; [k: string]: unknown } + +export interface PartyView { + id: string + shortcode: string + name?: string | null + description?: string | null + user?: { id?: string | null } | null + localId?: string | null + favorited?: boolean + fullAuto?: boolean + autoGuard?: boolean + autoSummon?: boolean + chargeAttack?: boolean + clearTime?: number + buttonCount?: number + chainCount?: number + turnCount?: number + visibility?: number + raid?: { name?: LocalizedName | null; group?: { difficulty?: number | null; extra?: boolean | null; guidebooks?: boolean | null; [k: string]: unknown } | null; [k: string]: unknown } | null + job?: { name?: LocalizedName | null; [k: string]: unknown } | null + weapons: GridWeaponItemView[] + summons: GridSummonItemView[] + characters: GridCharacterItemView[] + [k: string]: unknown +} + +// Helper for localized names +const LocalizedNameSchema = z.union([ + z.string(), + z.object({ + en: z.string().nullish(), + ja: z.string().nullish() + }) +]) + +// Minimal grid item guards (post camelCase) +const MinimalGridWeaponItemSchema = z + .object({ + position: z.number(), + mainhand: z.boolean().nullish().optional(), + object: z + .object({ + name: LocalizedNameSchema.nullish().optional() + }) + .passthrough() + .nullish() + .optional() + }) + .passthrough() + +const MinimalGridSummonItemSchema = z + .object({ + position: z.number(), + main: z.boolean().nullish().optional(), + friend: z.boolean().nullish().optional(), + quickSummon: z.boolean().nullish().optional(), + object: z + .object({ + name: LocalizedNameSchema.nullish().optional() + }) + .passthrough() + .nullish() + .optional() + }) + .passthrough() + +const MinimalGridCharacterItemSchema = z + .object({ + position: z.number(), + perpetuity: z.boolean().nullish().optional(), + transcendenceStep: z.number().nullish().optional(), + object: z + .object({ + name: LocalizedNameSchema.nullish().optional() + }) + .passthrough() + .nullish() + .optional() + }) + .passthrough() + +const MinimalGridsSchema = z + .object({ + weapons: z.array(MinimalGridWeaponItemSchema).optional(), + summons: z.array(MinimalGridSummonItemSchema).optional(), + characters: z.array(MinimalGridCharacterItemSchema).optional() + }) + .partial() + +// Minimal header associations (raid/job) and core scalar flags/counters +const MinimalHeaderSchema = z + .object({ + raid: z + .object({ + name: LocalizedNameSchema.nullish().optional(), + group: z + .object({ + difficulty: z.number().nullish().optional(), + extra: z.boolean().nullish().optional(), + guidebooks: z.boolean().nullish().optional() + }) + .passthrough() + .nullish() + .optional() + }) + .passthrough() + .nullish() + .optional(), + job: z + .object({ + name: LocalizedNameSchema.nullish().optional() + }) + .passthrough() + .nullish() + .optional() + }) + .partial() + +const MinimalScalarsSchema = z + .object({ + favorited: z.boolean().nullish().optional(), + fullAuto: z.boolean().nullish().optional(), + autoGuard: z.boolean().nullish().optional(), + autoSummon: z.boolean().nullish().optional(), + chargeAttack: z.boolean().nullish().optional(), + clearTime: z.number().nullish().optional(), + buttonCount: z.number().nullish().optional(), + chainCount: z.number().nullish().optional(), + turnCount: z.number().nullish().optional(), + visibility: z.number().nullish().optional() + }) + .partial() + +// User schema +const UserSchema = z.object({ + id: z.string(), + username: z.string(), + role: z.number().optional(), + granblue_id: z.number().nullish(), + avatar_url: z.string().nullish(), + crew_name: z.string().nullish() +}) + +// Raid and RaidGroup schemas +const RaidGroupSchema = z.object({ + id: z.string(), + name: LocalizedNameSchema, + difficulty: z.number(), + section: z.union([z.string(), z.number()]), // Can be number or string + order: z.number(), + extra: z.boolean().nullish(), + guidebooks: z.boolean().nullish() +}) + +const RaidSchema = z.object({ + id: z.string(), + name: LocalizedNameSchema, + slug: z.string().nullish(), + group: RaidGroupSchema.nullish(), + element: z.number().nullish() +}) + +// Job related schemas +const JobSchema = z.object({ + id: z.string(), + name: LocalizedNameSchema, + name_jp: z.string().optional(), + job_type: z.number().nullish(), + accessory_type: z.number().nullish(), + proficiency1: z.number().nullish(), + proficiency2: z.number().nullish(), + row: z.union([z.string(), z.number()]).nullish(), + order: z.number().nullish() +}) + +const JobSkillSchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string(), + cooldown: z.number().nullish(), + description: z.string().nullish(), + description_jp: z.string().nullish(), + main: z.boolean(), + sub: z.boolean(), + emp: z.boolean() +}) + +const JobAccessorySchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string() +}) + +// Item schemas +const WeaponSchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string().nullish(), + granblue_id: z.number().nullish(), + element: z.number().nullish(), + proficiency1: z.number().nullish(), + proficiency2: z.number().nullish(), + rarity: z.number().nullish(), + max_level: z.number().nullish(), + max_skill_level: z.number().nullish(), + series: z.number().nullish(), + icon_url: z.string().nullish(), + square_url: z.string().nullish() +}) + +const SummonSchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string().nullish(), + granblue_id: z.number().nullish(), + element: z.number().nullish(), + rarity: z.number().nullish(), + max_level: z.number().nullish(), + icon_url: z.string().nullish(), + square_url: z.string().nullish(), + main: z.boolean().optional(), + friend: z.boolean().optional(), + subaura: z.boolean().optional() +}) + +const CharacterSchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string().nullish(), + granblue_id: z.number().nullish(), + element: z.number().nullish(), + rarity: z.number().nullish(), + max_level: z.number().nullish(), + proficiency1: z.number().nullish(), + proficiency2: z.number().nullish(), + icon_url: z.string().nullish(), + square_url: z.string().nullish(), + special: z.boolean().optional() +}) + +const GuidebookSchema = z.object({ + id: z.string(), + name: z.string(), + name_jp: z.string().optional(), + slug: z.string(), + description: z.string().nullish(), + description_jp: z.string().nullish(), + granblue_id: z.number(), + icon_url: z.string().nullish(), + square_url: z.string().nullish() +}) + +// Ring/Earring schema for characters +const RingSchema = z.object({ + modifier: z.number().nullish(), + strength: z.number().nullish() +}).nullish() + +// Grid schemas +const GridWeaponSchema = z.object({ + id: z.string(), + party_id: z.string().nullish(), + weapon_id: z.string().nullish(), + position: z.number(), + mainhand: z.boolean().nullish(), + uncap_level: z.number().nullish().default(0), + transcendence_step: z.number().nullish().default(0), + transcendence_level: z.number().nullish().default(0), // Alias for compatibility + element: z.number().nullish(), + + // Weapon keys + weapon_key1_id: z.string().nullish(), + weapon_key2_id: z.string().nullish(), + weapon_key3_id: z.string().nullish(), + weapon_key4_id: z.string().nullish(), + weapon_keys: z.array(z.any()).nullish(), // Populated by API with key details + + // Awakening + awakening_id: z.string().nullish(), + awakening_level: z.number().nullish().default(1), + awakening: z.any().nullish(), // Populated by API with awakening details + + // AX modifiers + ax_modifier1: z.number().nullish(), + ax_strength1: z.number().nullish(), + ax_modifier2: z.number().nullish(), + ax_strength2: z.number().nullish(), + + // Nested weapon data (populated by API) + weapon: WeaponSchema.nullish(), + + created_at: z.string().nullish(), + updated_at: z.string().nullish() +}) + +const GridSummonSchema = z.object({ + id: z.string(), + party_id: z.string().nullish(), + summon_id: z.string().nullish(), + position: z.number(), + main: z.boolean().nullish(), + friend: z.boolean().nullish(), + uncap_level: z.number().nullish().default(0), + transcendence_step: z.number().nullish().default(0), + transcendence_level: z.number().nullish().default(0), // Alias for compatibility + quick_summon: z.boolean().nullish().default(false), + + // Nested summon data (populated by API) + summon: SummonSchema.nullish(), + + created_at: z.string().nullish(), + updated_at: z.string().nullish() +}) + +const GridCharacterSchema = z.object({ + id: z.string(), + party_id: z.string().nullish(), + character_id: z.string().nullish(), + position: z.number(), + uncap_level: z.number().nullish().default(0), + transcendence_step: z.number().nullish().default(0), + transcendence_level: z.number().nullish().default(0), // Alias for compatibility + perpetuity: z.boolean().nullish().default(false), + + // Rings and earring + ring1: RingSchema, + ring2: RingSchema, + ring3: RingSchema, + ring4: RingSchema, + earring: RingSchema, + + // Awakening + awakening_id: z.string().nullish(), + awakening_level: z.number().nullish().default(1), + + // Nested character data (populated by API) + character: CharacterSchema.nullish(), + + // Legacy field + over_mastery_level: z.number().nullish(), + + created_at: z.string().nullish(), + updated_at: z.string().nullish() +}) + +// Main Party schema - raw without transform +export const PartySchemaRaw = z.object({ + id: z.string(), + name: z.string().nullish(), + description: z.string().nullish(), + shortcode: z.string(), + visibility: z.number().nullish().default(1), + element: z.number().nullish(), + + // Battle settings + full_auto: z.boolean().nullish().default(false), + auto_guard: z.boolean().nullish().default(false), + auto_summon: z.boolean().nullish().default(false), + charge_attack: z.boolean().nullish().default(true), + + // Performance metrics + clear_time: z.number().nullish().default(0), + button_count: z.number().nullish(), + turn_count: z.number().nullish(), + chain_count: z.number().nullish(), + + // Relations + raid_id: z.string().nullish(), + raid: RaidSchema.nullish(), + job_id: z.string().nullish(), + job: JobSchema.nullish(), + user_id: z.string().nullish(), + user: UserSchema.nullish(), + + // Job details + master_level: z.number().nullish(), + ultimate_mastery: z.number().nullish(), + skill0_id: z.string().nullish(), + skill1_id: z.string().nullish(), + skill2_id: z.string().nullish(), + skill3_id: z.string().nullish(), + job_skills: z.union([ + z.array(z.any()), + z.record(z.any()) + ]).nullish().default([]), + accessory_id: z.string().nullish(), + accessory: JobAccessorySchema.nullish(), + + // Guidebooks + guidebook1_id: z.string().nullish(), + guidebook2_id: z.string().nullish(), + guidebook3_id: z.string().nullish(), + guidebooks: z.union([ + z.array(z.any()), + z.record(z.any()) + ]).nullish().default([]), + + // Grid arrays (may be empty or contain items with missing nested data) + characters: z.array(GridCharacterSchema).nullish().default([]), + weapons: z.array(GridWeaponSchema).nullish().default([]), + summons: z.array(GridSummonSchema).nullish().default([]), + + // Counts + weapons_count: z.number().nullish().default(0), + characters_count: z.number().nullish().default(0), + summons_count: z.number().nullish().default(0), + + // Metadata + extra: z.boolean().nullish().default(false), + favorited: z.boolean().nullish().default(false), + remix: z.boolean().nullish().default(false), + local_id: z.string().nullish(), + edit_key: z.string().nullish(), + source_party_id: z.string().nullish(), + source_party: z.any().nullish(), + remixes: z.array(z.any()).nullish().default([]), + + // Preview + preview_state: z.number().nullish().default(0), + preview_generated_at: z.string().nullish(), + preview_s3_key: z.string().nullish(), + + // Timestamps + created_at: z.string().nullish(), + updated_at: z.string().nullish() +}) + +// Apply transform after parsing (do NOT nest this schema inside other schemas) +// Keep exported for typing only; prefer parseParty() for runtime parsing. +export const PartySchema = PartySchemaRaw.transform(snakeToCamel) + +// Minimal schema for nested references +export const PartyMinimalSchema = z.object({ + id: z.string(), + shortcode: z.string(), + name: z.string().nullish(), + description: z.string().nullish(), + user: UserSchema.nullish() +}).transform(snakeToCamel) + +// Export types +// Type-level snake_case -> camelCase mapping +type CamelCase = S extends `${infer P}_${infer R}` + ? `${P}${Capitalize>}` + : S + +type CamelCasedKeysDeep = T extends Array + ? Array> + : T extends object + ? { [K in keyof T as K extends string ? CamelCase : K]: CamelCasedKeysDeep } + : T + +export type Party = CamelCasedKeysDeep> +export type PartyMinimal = CamelCasedKeysDeep> +export type GridWeapon = CamelCasedKeysDeep> +export type GridSummon = CamelCasedKeysDeep> +export type GridCharacter = CamelCasedKeysDeep> +export type Weapon = CamelCasedKeysDeep> +export type Summon = CamelCasedKeysDeep> +export type Character = CamelCasedKeysDeep> +export type Job = CamelCasedKeysDeep> +export type JobSkill = CamelCasedKeysDeep> +export type JobAccessory = CamelCasedKeysDeep> +export type Raid = CamelCasedKeysDeep> +export type RaidGroup = CamelCasedKeysDeep> +export type User = CamelCasedKeysDeep> +export type Guidebook = CamelCasedKeysDeep> + +// Helper: parse raw API party (snake_case) and convert to camelCase +export function parseParty(input: unknown): Party { + // Step 1: convert server snake_case to client camelCase + const camel = snakeToCamel(input) as Record + // Step 2: validate only minimal, core fields and pass through the rest + const parsed = MinimalCamelPartySchema.parse(camel) as any + + // Step 3: minimally validate grids and coerce to arrays for UI safety + const grids = MinimalGridsSchema.safeParse(camel) + if (grids.success) { + parsed.weapons = grids.data.weapons ?? [] + parsed.summons = grids.data.summons ?? [] + parsed.characters = grids.data.characters ?? [] + } else { + parsed.weapons = Array.isArray(parsed.weapons) ? parsed.weapons : [] + parsed.summons = Array.isArray(parsed.summons) ? parsed.summons : [] + parsed.characters = Array.isArray(parsed.characters) ? parsed.characters : [] + } + + // Step 4: minimally validate header associations (raid/job) + const hdr = MinimalHeaderSchema.safeParse(camel) + if (hdr.success) { + if (hdr.data.raid !== undefined) parsed.raid = hdr.data.raid + if (hdr.data.job !== undefined) parsed.job = hdr.data.job + } + + // Step 5: core scalars with safe defaults for UI + const scal = MinimalScalarsSchema.safeParse(camel) + const pick = (v: T | null | undefined, d: T) => (v ?? d) + if (scal.success) { + parsed.favorited = pick(scal.data.favorited ?? (parsed as any).favorited, false) + parsed.fullAuto = pick(scal.data.fullAuto ?? (parsed as any).fullAuto, false) + parsed.autoGuard = pick(scal.data.autoGuard ?? (parsed as any).autoGuard, false) + parsed.autoSummon = pick(scal.data.autoSummon ?? (parsed as any).autoSummon, false) + parsed.chargeAttack = pick(scal.data.chargeAttack ?? (parsed as any).chargeAttack, true) + parsed.clearTime = pick(scal.data.clearTime ?? (parsed as any).clearTime, 0) + parsed.buttonCount = pick(scal.data.buttonCount ?? (parsed as any).buttonCount, 0) + parsed.chainCount = pick(scal.data.chainCount ?? (parsed as any).chainCount, 0) + parsed.turnCount = pick(scal.data.turnCount ?? (parsed as any).turnCount, 0) + parsed.visibility = pick(scal.data.visibility ?? (parsed as any).visibility, 1) + } + + return parsed as Party +} diff --git a/src/lib/api/schemas/transforms.ts b/src/lib/api/schemas/transforms.ts new file mode 100644 index 00000000..f2b47f2f --- /dev/null +++ b/src/lib/api/schemas/transforms.ts @@ -0,0 +1,43 @@ +/** + * Transforms snake_case keys to camelCase + */ +export function snakeToCamel(obj: T): T { + if (obj === null || obj === undefined) return obj + + if (Array.isArray(obj)) { + return obj.map(snakeToCamel) as T + } + + if (typeof obj === 'object') { + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + result[camelKey] = snakeToCamel(value) + } + return result + } + + return obj +} + +/** + * Transforms camelCase keys to snake_case + */ +export function camelToSnake(obj: T): T { + if (obj === null || obj === undefined) return obj + + if (Array.isArray(obj)) { + return obj.map(camelToSnake) as T + } + + if (typeof obj === 'object') { + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) + result[snakeKey] = camelToSnake(value) + } + return result + } + + return obj +} \ No newline at end of file