1"use client"
2
3import type { LayoutId } from "@/lib/layouts"
4import { RELAY_SLOT_BYTE_LIMIT, RELAY_SLOT_MIME } from "@/lib/schemas"
5import type { RelayRole } from "@/lib/relaySlots"
6import { messageFrom, putToSignedUrl, type SignedUpload } from "@/lib/http"
7
8/* M8 (SE-01 sampai SE-09): panggilan jaringan alur estafet. Hanya berkas ini
9 yang berbicara dengan rute estafet dan bucket dari sisi klien, sama seperti
10 features/share/api.ts untuk strip.
11
12 Slot diunggah lewat URL presigned R2 (M10; badan berkas tidak melewati rute
13 server). Unggahan slot teman diminta ulang saat penuntasan bila berkasnya
14 belum ada: rute complete menjawab 422 beserta URL unggah untuk slot yang
15 kurang, dan RelaySlotsNeededError membawa daftar itu ke pemanggil. */
16
17interface RelaySlotUpload extends SignedUpload {
18 index: number
19}
20
21interface CreatedRelay {
22 id: string
23 hostToken: string
24 expiresAt: string | null
25 uploads: RelaySlotUpload[]
26}
27
28interface ClaimedRelay {
29 guestToken: string
30 relay: {
31 id: string
32 layout: LayoutId
33 expiresAt: string | null
34 slots: { index: number; owner: RelayRole }[]
35 }
36}
37
38export interface CompletedRelay {
39 stripId: string
40 /** Ada saat estafetnya baru dituntaskan atau drafnya diterbitkan ulang. */
41 deleteToken?: string
42 upload?: SignedUpload
43 /** Ada saat strip estafetnya sudah selesai lebih dulu. */
44 expiresAt?: string | null
45}
46
47const CREATE_FAILED = "Link estafet belum bisa dibuat. Coba lagi sebentar lagi."
48const CLAIM_FAILED = "Giliran belum bisa diambil. Coba lagi sebentar lagi."
49const COMPLETE_FAILED =
50 "Estafet belum bisa diselesaikan. Coba lagi sebentar lagi."
51const DELETE_FAILED = "Estafet belum bisa dihapus. Coba lagi sebentar lagi."
52
53/** Slot teman belum lengkap di Storage: rute complete mengirim URL unggah
54 * pengganti bersama galatnya, dan pemanggil mengunggah dari foto yang ada di
55 * sesinya lalu mencoba sekali lagi. */
56export class RelaySlotsNeededError extends Error {
57 readonly uploads: RelaySlotUpload[]
58
59 constructor(message: string, uploads: RelaySlotUpload[]) {
60 super(message)
61 this.name = "RelaySlotsNeededError"
62 this.uploads = uploads
63 }
64}
65
66export async function createRelay(layoutId: LayoutId): Promise<CreatedRelay> {
67 const response = await fetch("/api/relays", {
68 method: "POST",
69 headers: { "content-type": "application/json" },
70 body: JSON.stringify({ layout: layoutId }),
71 })
72 if (!response.ok) {
73 throw new Error(await messageFrom(response, CREATE_FAILED))
74 }
75 return (await response.json()) as CreatedRelay
76}
77
78export async function uploadRelaySlot(
79 upload: SignedUpload,
80 blob: Blob
81): Promise<void> {
82 /* Pra-cek klien: batas 1 MB rute tetap pagar sebenarnya, tapi berkas yang
83 jelas terlalu besar tidak perlu dikirim. */
84 if (blob.size > RELAY_SLOT_BYTE_LIMIT) {
85 throw new Error("Fotonya terlalu besar. Ambil ulang fotonya.")
86 }
87 await putToSignedUrl(
88 upload,
89 blob,
90 RELAY_SLOT_MIME,
91 "Fotonya belum bisa diunggah. Coba lagi sebentar lagi."
92 )
93}
94
95export async function claimRelay(id: string): Promise<ClaimedRelay> {
96 const response = await fetch(`/api/relays/${id}/claim`, {
97 method: "POST",
98 /* Jenis isi JSON wajib: tanpanya rute menolak, supaya POST lintas situs
99 tanpa preflight tidak bisa membakar giliran sekali pakai ini. */
100 headers: { "content-type": "application/json" },
101 body: "{}",
102 })
103 if (!response.ok) {
104 throw new Error(await messageFrom(response, CLAIM_FAILED))
105 }
106 return (await response.json()) as ClaimedRelay
107}
108
109export async function completeRelay(
110 id: string,
111 token: string
112): Promise<CompletedRelay> {
113 const response = await fetch(`/api/relays/${id}/complete`, {
114 method: "POST",
115 headers: { "x-relay-token": token },
116 })
117 if (response.status === 422) {
118 const body = (await response.json()) as {
119 error?: unknown
120 uploads?: RelaySlotUpload[]
121 }
122 throw new RelaySlotsNeededError(
123 typeof body.error === "string" && body.error !== ""
124 ? body.error
125 : COMPLETE_FAILED,
126 body.uploads ?? []
127 )
128 }
129 if (!response.ok) {
130 throw new Error(await messageFrom(response, COMPLETE_FAILED))
131 }
132 return (await response.json()) as CompletedRelay
133}
134
135/** M8 (SE-07, SE-10): status estafet yang sudah selesai, hanya untuk pemegang
136 * token. Halaman /r/[id] tidak mengirim tautan strip atau URL foto ke
137 * pengunjung; klien memintanya di sini setelah tokennya terbaca. */
138export interface RelayStripInfo {
139 stripId: string | null
140 expiresAt: string | null
141 role: RelayRole
142 slots: { index: number; url: string }[]
143}
144
145export async function fetchRelayStrip(
146 id: string,
147 token: string
148): Promise<RelayStripInfo> {
149 const response = await fetch(`/api/relays/${id}/strip`, {
150 method: "POST",
151 headers: { "x-relay-token": token },
152 })
153 if (!response.ok) {
154 throw new Error(await messageFrom(response, COMPLETE_FAILED))
155 }
156 return (await response.json()) as RelayStripInfo
157}
158
159export async function cancelRelay(id: string, token: string): Promise<void> {
160 const response = await fetch(`/api/relays/${id}`, {
161 method: "DELETE",
162 headers: { "x-relay-token": token },
163 })
164 if (!response.ok) {
165 throw new Error(await messageFrom(response, DELETE_FAILED))
166 }
167}
168
169/** Foto sesi disimpan sebagai dataURL JPEG; Storage menerima Blob. */
170export function dataUrlToBlob(dataUrl: string): Blob {
171 const meta = dataUrl.slice(0, dataUrl.indexOf(",") + 1)
172 const mime = meta.slice(meta.indexOf(":") + 1, meta.indexOf(";"))
173 const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1))
174 const bytes = new Uint8Array(binary.length)
175 for (let index = 0; index < binary.length; index += 1) {
176 bytes[index] = binary.charCodeAt(index)
177 }
178 return new Blob([bytes], { type: mime === "" ? RELAY_SLOT_MIME : mime })
179}
180