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 if (!draftData.title.trim()) {
361 errors.push("trail needs a title");
362 }
363
364 if (!draftData.description.trim()) {
365 errors.push("trail needs a description");
366 }
367
368 if (draftData.stops.length < 2) {
369 errors.push("trails need at least 2 stops to be published");
370 }
371
372 if (draftData.stops.length > 12) {
373 errors.push("trails can have a maximum of 12 stops");
374 }
375
376 for (let i = 0; i < draftData.stops.length; i++) {
377 const stop = draftData.stops[i];
378 const stopErrors: string[] = [];
379
380 if (!stop.title.trim()) {
381 stopErrors.push("needs a title");
382 }
383
384 if (!stop.content.trim()) {
385 stopErrors.push("needs content");
386 }
387
388 if (stopErrors.length > 0) {
389 inlineErrors[stop.tid] = stopErrors.join(", ");
390 errors.push(`stop ${i + 1} ${stopErrors.join(", ")}`);
391 }
392 }
393
394 if (errors.length > 0) {
395 return {
396 success: false,
397 errors,
398 inlineErrors,
399 };
400 }
401
402 const client = await getLexClient();
403 const db = getDb();
404 const now = new Date().toISOString();
405
406 const stops = await Promise.all(
407 draftData.stops.map(async (stop) => {
408 let thumbBlob: l.BlobRef | undefined = undefined;
409
410 if (stop.external?.thumb) {
411 try {
412 const response = await fetch(stop.external.thumb);
413 if (response.ok) {
414 const blob = await response.blob();
415 const buffer = await blob.arrayBuffer();
416
417 const uploadResponse = await client.call(uploadBlob.main, new Uint8Array(buffer));
418 thumbBlob = uploadResponse.blob as l.BlobRef;
419 }
420 } catch (error) {
421 console.error("Failed to upload thumbnail:", error);
422 }
423 }
424
425 const stopRecord: trail.Stop = {
426 tid: generateTid(),
427 title: stop.title,
428 content: stop.content,
429 };
430
431 if (stop.buttonText) {
432 stopRecord.buttonText = stop.buttonText;
433 }
434
435 if (stop.external) {
436 const resolvedUri = await resolveAtUri(stop.external.uri);
437 const external: trail.External = {
438 uri: resolvedUri as l.Uri,
439 };
440 if (stop.external.title) {
441 external.title = stop.external.title;
442 }
443 if (stop.external.description) {
444 external.description = stop.external.description;
445 }
446 if (thumbBlob) {
447 external.thumb = thumbBlob;
448 }
449 stopRecord.external = external;
450 }
451
452 return stopRecord;
453 }),
454 );
455
456 const rkey = generateTid();
457 const authorDid = client.assertDid;
458 const uri = `at://${authorDid}/app.sidetrail.trail/${rkey}`;
459
460 const currentUser = await loadCurrentUser();
461 const handle = currentUser?.handle ?? "unknown";
462
463 const trailRecord: TrailRecord = {
464 $type: "app.sidetrail.trail",
465 title: draftData.title,
466 description: draftData.description,
467 stops: stops.map((s) => {
468 const stop: TrailRecord["stops"][0] = {
469 tid: s.tid,
470 title: s.title,
471 content: s.content,
472 };
473 if (s.buttonText) stop.buttonText = s.buttonText;
474 if (s.external) {
475 const external: NonNullable<TrailRecord["stops"][0]["external"]> = {
476 uri: s.external.uri,
477 };
478 if (s.external.title) external.title = s.external.title;
479 if (s.external.description) external.description = s.external.description;
480 if (s.external.thumb) external.thumb = s.external.thumb as typeof external.thumb;
481 stop.external = external;
482 }
483 return stop;
484 }),
485 accentColor: draftData.accentColor,
486 backgroundColor: draftData.backgroundColor,
487 createdAt: now,
488 };
489
490 const cid = (await cidForLex(trailRecord)).toString();
491
492 await db.insert(trails).values({
493 uri,
494 cid,
495 authorDid,
496 rkey,
497 record: trailRecord,
498 createdAt: new Date(now),
499 });
500
501 after(async () => {
502 await client.create(
503 trail.main,
504 {
505 title: draftData.title,
506 description: draftData.description,
507 stops,
508 accentColor: draftData.accentColor,
509 backgroundColor: draftData.backgroundColor,
510 createdAt: now,
511 },
512 { rkey },
513 );
514 });
515
516 return { success: true, uri, rkey, handle };
517}
518
519export async function deleteTrail(trailUri: string): Promise<void> {
520 const client = await getLexClient();
521 const db = getDb();
522
523 const trailRkey = extractRkey(trailUri);
524
525 await db.delete(trails).where(eq(trails.uri, trailUri));
526
527 after(async () => {
528 await client.delete(trail.main, { rkey: trailRkey });
529 });
530}