an app to share curated trails
sidetrail.app
1"use server";
2
3import "server-only";
4import { after } from "next/server";
5import { getLexClient } from "@/data/lex-client";
6import { generateTid } from "./tid";
7import { loadCurrentUser, resolveHandleToDid } from "./queries";
8import { refresh } from "next/cache";
9import * as trail from "@/lib/lexicons/app/sidetrail/trail";
10import * as walk from "@/lib/lexicons/app/sidetrail/walk";
11import * as completion from "@/lib/lexicons/app/sidetrail/completion";
12import * as uploadBlob from "@/lib/lexicons/com/atproto/repo/uploadBlob";
13import type { l } from "@atproto/lex";
14import {
15 getDb,
16 trails,
17 walks as walksTable,
18 completions as completionsTable,
19 type TrailRecord,
20 type WalkRecord,
21 type CompletionRecord,
22} from "@/data/db";
23import { eq, and } from "drizzle-orm";
24import { cidForLex } from "@atproto/lex-cbor";
25import { AtUri } from "@atproto/syntax";
26
27// ============================================================================
28// Helpers
29// ============================================================================
30
31function extractRkey(uri: string): string {
32 const parts = uri.split("/");
33 return parts[parts.length - 1];
34}
35
36/**
37 * Resolves an at:// URI to use a DID instead of a handle.
38 * If the URI already uses a DID or is not an at:// URI, returns it unchanged.
39 */
40async function resolveAtUri(uri: string): Promise<string> {
41 if (!uri.startsWith("at://")) {
42 return uri;
43 }
44
45 const parsed = new AtUri(uri);
46 if (parsed.host.startsWith("did:")) {
47 return uri;
48 }
49
50 const did = await resolveHandleToDid(parsed.host);
51 return `at://${did}/${parsed.collection}/${parsed.rkey}`;
52}
53
54// ============================================================================
55// Walk Mutations
56// ============================================================================
57
58export async function startWalk(trailUri: string, trailCid: string): Promise<void> {
59 const client = await getLexClient();
60 const db = getDb();
61 const authorDid = client.assertDid;
62
63 const [existingWalk] = await db
64 .select({ uri: walksTable.uri })
65 .from(walksTable)
66 .where(and(eq(walksTable.authorDid, authorDid), eq(walksTable.trailUri, trailUri)))
67 .limit(1);
68
69 if (existingWalk) {
70 return;
71 }
72
73 const [trailRow] = await db.select().from(trails).where(eq(trails.uri, trailUri)).limit(1);
74
75 if (!trailRow) {
76 throw new Error("Trail not found");
77 }
78
79 const trailData = trailRow.record;
80
81 if (!trailData.stops || trailData.stops.length === 0) {
82 throw new Error("Trail has no stops");
83 }
84
85 const now = new Date().toISOString();
86 const firstStopTid = trailData.stops[0].tid;
87
88 const rkey = generateTid();
89 const uri = `at://${authorDid}/app.sidetrail.walk/${rkey}`;
90
91 const walkRecord: WalkRecord = {
92 $type: "app.sidetrail.walk",
93 trail: { uri: trailUri, cid: trailCid },
94 visitedStops: [firstStopTid],
95 createdAt: now,
96 updatedAt: now,
97 };
98
99 const cid = (await cidForLex(walkRecord)).toString();
100
101 await db.insert(walksTable).values({
102 uri,
103 cid,
104 authorDid,
105 rkey,
106 trailUri,
107 record: walkRecord,
108 createdAt: new Date(now),
109 });
110
111 after(async () => {
112 await client.create(
113 walk.main,
114 {
115 trail: {
116 uri: trailUri as l.AtUri,
117 cid: trailCid,
118 },
119 visitedStops: [firstStopTid],
120 createdAt: now,
121 updatedAt: now,
122 },
123 { rkey },
124 );
125 });
126
127 refresh();
128}
129
130export async function visitStop(walkUri: string, stopTid: string): Promise<void> {
131 const client = await getLexClient();
132 const db = getDb();
133
134 const walkRkey = extractRkey(walkUri);
135
136 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1);
137
138 if (!walkRow) {
139 throw new Error("Walk not found");
140 }
141
142 const walkData = walkRow.record;
143
144 const visitedStops = walkData.visitedStops.includes(stopTid)
145 ? [...walkData.visitedStops.filter((t) => t !== stopTid), stopTid]
146 : [...walkData.visitedStops, stopTid];
147 const now = new Date().toISOString();
148
149 const updatedRecord: WalkRecord = {
150 $type: "app.sidetrail.walk",
151 trail: { uri: walkData.trail.uri, cid: walkData.trail.cid },
152 visitedStops,
153 createdAt: walkData.createdAt,
154 updatedAt: now,
155 };
156
157 await db.update(walksTable).set({ record: updatedRecord }).where(eq(walksTable.uri, walkUri));
158
159 after(async () => {
160 await client.put(
161 walk.main,
162 {
163 trail: {
164 uri: walkData.trail.uri as l.AtUri,
165 cid: walkData.trail.cid,
166 },
167 visitedStops,
168 createdAt: walkData.createdAt as l.Datetime,
169 updatedAt: now,
170 },
171 { rkey: walkRkey },
172 );
173 });
174
175 refresh();
176}
177
178export async function completeTrail(walkUri: string): Promise<void> {
179 const client = await getLexClient();
180 const db = getDb();
181 const authorDid = client.assertDid;
182
183 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1);
184
185 if (!walkRow) {
186 throw new Error("Walk not found");
187 }
188
189 const walkData = walkRow.record;
190 const trailUri = walkData.trail.uri;
191 const now = new Date().toISOString();
192
193 const allWalks = await db
194 .select()
195 .from(walksTable)
196 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
197
198 const completionRkey = generateTid();
199 const completionUri = `at://${authorDid}/app.sidetrail.completion/${completionRkey}`;
200
201 const completionRecord: CompletionRecord = {
202 $type: "app.sidetrail.completion",
203 trail: { uri: trailUri, cid: walkData.trail.cid },
204 createdAt: now,
205 };
206
207 const cid = (await cidForLex(completionRecord)).toString();
208
209 await db.insert(completionsTable).values({
210 uri: completionUri,
211 cid,
212 authorDid,
213 rkey: completionRkey,
214 trailUri,
215 record: completionRecord,
216 createdAt: new Date(now),
217 });
218
219 await db
220 .delete(walksTable)
221 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
222
223 after(async () => {
224 await client.create(
225 completion.main,
226 {
227 trail: {
228 uri: trailUri as l.AtUri,
229 cid: walkData.trail.cid,
230 },
231 createdAt: now,
232 },
233 { rkey: completionRkey },
234 );
235 for (const w of allWalks) {
236 await client.delete(walk.main, { rkey: w.rkey });
237 }
238 });
239
240 refresh();
241}
242
243export async function abandonWalk(walkUri: string): Promise<void> {
244 const client = await getLexClient();
245 const db = getDb();
246 const authorDid = client.assertDid;
247
248 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1);
249
250 if (!walkRow) {
251 return;
252 }
253
254 const trailUri = walkRow.record.trail.uri;
255
256 const allWalks = await db
257 .select()
258 .from(walksTable)
259 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
260
261 await db
262 .delete(walksTable)
263 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
264
265 after(async () => {
266 for (const w of allWalks) {
267 await client.delete(walk.main, { rkey: w.rkey });
268 }
269 });
270
271 refresh();
272}
273
274// ============================================================================
275// Completion Mutations
276// ============================================================================
277
278export async function deleteCompletion(completionUri: string): Promise<void> {
279 const client = await getLexClient();
280 const db = getDb();
281
282 const completionRkey = extractRkey(completionUri);
283
284 await db.delete(completionsTable).where(eq(completionsTable.uri, completionUri));
285
286 after(async () => {
287 await client.delete(completion.main, { rkey: completionRkey });
288 });
289
290 refresh();
291}
292
293export async function forgetTrail(trailUri: string): Promise<void> {
294 const client = await getLexClient();
295 const db = getDb();
296
297 const authorDid = client.assertDid;
298
299 const matchingWalks = await db
300 .select()
301 .from(walksTable)
302 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
303
304 const matchingCompletions = await db
305 .select()
306 .from(completionsTable)
307 .where(and(eq(completionsTable.trailUri, trailUri), eq(completionsTable.authorDid, authorDid)));
308
309 await db
310 .delete(walksTable)
311 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid)));
312
313 await db
314 .delete(completionsTable)
315 .where(and(eq(completionsTable.trailUri, trailUri), eq(completionsTable.authorDid, authorDid)));
316
317 after(async () => {
318 for (const w of matchingWalks) {
319 await client.delete(walk.main, { rkey: w.rkey });
320 }
321 for (const c of matchingCompletions) {
322 await client.delete(completion.main, { rkey: c.rkey });
323 }
324 });
325
326 refresh();
327}
328
329// ============================================================================
330// Trail Mutations
331// ============================================================================
332
333type PublishDraftResult =
334 | { success: true; uri: string; rkey: string; handle: string }
335 | { success: false; errors: string[]; inlineErrors: Record<string, string> };
336
337type ExternalEmbedInput = {
338 uri: string;
339 title?: string;
340 description?: string;
341 thumb?: string; // URL string from draft
342};
343
344export async function publishDraft(draftData: {
345 title: string;
346 description: string;
347 stops: Array<{
348 tid: string;
349 title: string;
350 content: string;
351 buttonText?: string;
352 external?: ExternalEmbedInput;
353 }>;
354 accentColor: string;
355 backgroundColor: string;
356}): Promise<PublishDraftResult> {
357 const errors: string[] = [];
358 const inlineErrors: Record<string, string> = {};
359
360 // Length limits mirror the lexicon (lexicons/app/sidetrail/trail.json) -
361 // records that exceed them would be rejected by our own ingester.
362 const graphemes = (s: string) => [...new Intl.Segmenter().segment(s)].length;
363
364 if (!draftData.title.trim()) {
365 errors.push("trail needs a title");
366 } else if (graphemes(draftData.title) > 64) {
367 errors.push("trail title is too long (max 64 characters)");
368 }
369
370 if (!draftData.description.trim()) {
371 errors.push("trail needs a description");
372 } else if (graphemes(draftData.description) > 300) {
373 errors.push("trail description is too long (max 300 characters)");
374 }
375
376 if (draftData.stops.length < 2) {
377 errors.push("trails need at least 2 stops to be published");
378 }
379
380 if (draftData.stops.length > 24) {
381 errors.push("trails can have a maximum of 24 stops");
382 }
383
384 for (let i = 0; i < draftData.stops.length; i++) {
385 const stop = draftData.stops[i];
386 const stopErrors: string[] = [];
387
388 if (!stop.title.trim()) {
389 stopErrors.push("needs a title");
390 } else if (graphemes(stop.title) > 128) {
391 stopErrors.push("title is too long (max 128 characters)");
392 }
393
394 if (!stop.content.trim()) {
395 stopErrors.push("needs content");
396 } else if (graphemes(stop.content) > 5000) {
397 stopErrors.push("content is too long (max 5000 characters)");
398 }
399
400 if (stop.buttonText && graphemes(stop.buttonText) > 64) {
401 stopErrors.push("button text is too long (max 64 characters)");
402 }
403
404 if (stopErrors.length > 0) {
405 inlineErrors[stop.tid] = stopErrors.join(", ");
406 errors.push(`stop ${i + 1} ${stopErrors.join(", ")}`);
407 }
408 }
409
410 if (errors.length > 0) {
411 return {
412 success: false,
413 errors,
414 inlineErrors,
415 };
416 }
417
418 const client = await getLexClient();
419 const db = getDb();
420 const now = new Date().toISOString();
421
422 const stops = await Promise.all(
423 draftData.stops.map(async (stop) => {
424 let thumbBlob: l.BlobRef | undefined = undefined;
425
426 if (stop.external?.thumb) {
427 try {
428 const response = await fetch(stop.external.thumb);
429 if (response.ok) {
430 const blob = await response.blob();
431 const buffer = await blob.arrayBuffer();
432
433 const uploadResponse = await client.call(uploadBlob.main, new Uint8Array(buffer));
434 thumbBlob = uploadResponse.blob as l.BlobRef;
435 }
436 } catch (error) {
437 console.error("Failed to upload thumbnail:", error);
438 }
439 }
440
441 const stopRecord: trail.Stop = {
442 tid: generateTid(),
443 title: stop.title,
444 content: stop.content,
445 };
446
447 if (stop.buttonText) {
448 stopRecord.buttonText = stop.buttonText;
449 }
450
451 if (stop.external) {
452 const resolvedUri = await resolveAtUri(stop.external.uri);
453 const external: trail.External = {
454 uri: resolvedUri as l.Uri,
455 };
456 if (stop.external.title) {
457 external.title = stop.external.title;
458 }
459 if (stop.external.description) {
460 external.description = stop.external.description;
461 }
462 if (thumbBlob) {
463 external.thumb = thumbBlob;
464 }
465 stopRecord.external = external;
466 }
467
468 return stopRecord;
469 }),
470 );
471
472 const rkey = generateTid();
473 const authorDid = client.assertDid;
474 const uri = `at://${authorDid}/app.sidetrail.trail/${rkey}`;
475
476 const currentUser = await loadCurrentUser();
477 const handle = currentUser?.handle ?? "unknown";
478
479 const trailRecord: TrailRecord = {
480 $type: "app.sidetrail.trail",
481 title: draftData.title,
482 description: draftData.description,
483 stops: stops.map((s) => {
484 const stop: TrailRecord["stops"][0] = {
485 tid: s.tid,
486 title: s.title,
487 content: s.content,
488 };
489 if (s.buttonText) stop.buttonText = s.buttonText;
490 if (s.external) {
491 const external: NonNullable<TrailRecord["stops"][0]["external"]> = {
492 uri: s.external.uri,
493 };
494 if (s.external.title) external.title = s.external.title;
495 if (s.external.description) external.description = s.external.description;
496 if (s.external.thumb) external.thumb = s.external.thumb as typeof external.thumb;
497 stop.external = external;
498 }
499 return stop;
500 }),
501 accentColor: draftData.accentColor,
502 backgroundColor: draftData.backgroundColor,
503 createdAt: now,
504 };
505
506 const cid = (await cidForLex(trailRecord)).toString();
507
508 await db.insert(trails).values({
509 uri,
510 cid,
511 authorDid,
512 rkey,
513 record: trailRecord,
514 createdAt: new Date(now),
515 });
516
517 after(async () => {
518 await client.create(
519 trail.main,
520 {
521 title: draftData.title,
522 description: draftData.description,
523 stops,
524 accentColor: draftData.accentColor,
525 backgroundColor: draftData.backgroundColor,
526 createdAt: now,
527 },
528 { rkey },
529 );
530 });
531
532 return { success: true, uri, rkey, handle };
533}
534
535export async function deleteTrail(trailUri: string): Promise<void> {
536 const client = await getLexClient();
537 const db = getDb();
538
539 const trailRkey = extractRkey(trailUri);
540
541 await db.delete(trails).where(eq(trails.uri, trailUri));
542
543 after(async () => {
544 await client.delete(trail.main, { rkey: trailRkey });
545 });
546}