Skip to content

Commit b3b65fc

Browse files
committed
feat(links): support optional UTM parameters on short links
Lets users attach utm_source / utm_medium / utm_campaign / utm_term / utm_content to a short link as structured fields instead of baking them into the destination URL by hand. UTMs are merged into link.url at redirect time, preserving any pre-existing query string and overwriting conflicting UTMs (the structured field is the source of truth). The form's UTM section is collapsed by default and auto-expands when editing a link that already has any UTM value set. An empty-string-to- null Zod transform keeps the DB free of meaningless empty strings. Adds Redis cache invalidation in createLink/updateLink/deleteLink so edits to url/slug/UTMs take effect immediately instead of waiting for the 24h redirect-cache TTL — including a defensive del in createLink to guard against slug-reuse-after-hard-delete poisoning the cache.
1 parent fa182d0 commit b3b65fc

8 files changed

Lines changed: 178 additions & 18 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- AlterTable
2+
ALTER TABLE "link"
3+
ADD COLUMN "utm_source" VARCHAR(255),
4+
ADD COLUMN "utm_medium" VARCHAR(255),
5+
ADD COLUMN "utm_campaign" VARCHAR(255),
6+
ADD COLUMN "utm_term" VARCHAR(255),
7+
ADD COLUMN "utm_content" VARCHAR(255);

prisma/schema.prisma

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -287,15 +287,20 @@ model Revenue {
287287
}
288288

289289
model Link {
290-
id String @id() @map("link_id") @db.Uuid
291-
name String @db.VarChar(100)
292-
url String @db.VarChar(500)
293-
slug String @unique() @db.VarChar(100)
294-
userId String? @map("user_id") @db.Uuid
295-
teamId String? @map("team_id") @db.Uuid
296-
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
297-
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
298-
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
290+
id String @id() @map("link_id") @db.Uuid
291+
name String @db.VarChar(100)
292+
url String @db.VarChar(500)
293+
slug String @unique() @db.VarChar(100)
294+
utmSource String? @map("utm_source") @db.VarChar(255)
295+
utmMedium String? @map("utm_medium") @db.VarChar(255)
296+
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
297+
utmTerm String? @map("utm_term") @db.VarChar(255)
298+
utmContent String? @map("utm_content") @db.VarChar(255)
299+
userId String? @map("user_id") @db.Uuid
300+
teamId String? @map("team_id") @db.Uuid
301+
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
302+
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
303+
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
299304
300305
user User? @relation("user", fields: [userId], references: [id])
301306
team Team? @relation(fields: [teamId], references: [id])

src/app/(collect)/q/[slug]/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { POST } from '@/app/api/send/route';
55
import type { Link } from '@/generated/prisma/client';
66
import redis from '@/lib/redis';
77
import { notFound } from '@/lib/response';
8+
import { appendQueryParams } from '@/lib/url';
89
import { findLink } from '@/queries/prisma';
910

1011
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
@@ -59,5 +60,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
5960

6061
await POST(req);
6162

62-
return NextResponse.redirect(link.url);
63+
const target = appendQueryParams(link.url, {
64+
utm_source: link.utmSource,
65+
utm_medium: link.utmMedium,
66+
utm_campaign: link.utmCampaign,
67+
utm_term: link.utmTerm,
68+
utm_content: link.utmContent,
69+
});
70+
71+
return NextResponse.redirect(target);
6372
}

src/app/(main)/links/LinkEditForm.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import {
1111
Row,
1212
TextField,
1313
} from '@umami/react-zen';
14-
import { useState } from 'react';
14+
import { useEffect, useState } from 'react';
1515
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
1616
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
17-
import { RefreshCw } from '@/components/icons';
17+
import { ChevronDown, RefreshCw } from '@/components/icons';
1818
import { LINKS_URL } from '@/lib/constants';
1919
import { getRandomChars } from '@/lib/generate';
2020
import { isValidUrl } from '@/lib/url';
@@ -46,6 +46,20 @@ export function LinkEditForm({
4646
const hostUrl = linksUrl || LINKS_URL;
4747
const { data, isLoading } = useLinkQuery(linkId);
4848
const [defaultSlug] = useState(generateId());
49+
const [utmExpanded, setUtmExpanded] = useState(false);
50+
51+
useEffect(() => {
52+
if (
53+
data &&
54+
(data.utmSource ||
55+
data.utmMedium ||
56+
data.utmCampaign ||
57+
data.utmTerm ||
58+
data.utmContent)
59+
) {
60+
setUtmExpanded(true);
61+
}
62+
}, [data]);
4963

5064
const handleSubmit = async (data: any) => {
5165
await mutateAsync(data, {
@@ -93,6 +107,38 @@ export function LinkEditForm({
93107
<TextField placeholder="https://example.com" autoComplete="off" />
94108
</FormField>
95109

110+
<Column gap="2">
111+
<Button
112+
variant="quiet"
113+
onPress={() => setUtmExpanded(v => !v)}
114+
style={{ alignSelf: 'flex-start' }}
115+
>
116+
<Icon rotate={utmExpanded ? 180 : 0}>
117+
<ChevronDown />
118+
</Icon>
119+
{t(labels.utm)}
120+
</Button>
121+
{utmExpanded && (
122+
<>
123+
<FormField label={t(labels.utmSource)} name="utmSource">
124+
<TextField autoComplete="off" maxLength={255} />
125+
</FormField>
126+
<FormField label={t(labels.utmMedium)} name="utmMedium">
127+
<TextField autoComplete="off" maxLength={255} />
128+
</FormField>
129+
<FormField label={t(labels.utmCampaign)} name="utmCampaign">
130+
<TextField autoComplete="off" maxLength={255} />
131+
</FormField>
132+
<FormField label={t(labels.utmTerm)} name="utmTerm">
133+
<TextField autoComplete="off" maxLength={255} />
134+
</FormField>
135+
<FormField label={t(labels.utmContent)} name="utmContent">
136+
<TextField autoComplete="off" maxLength={255} />
137+
</FormField>
138+
</>
139+
)}
140+
</Column>
141+
96142
{cloudMode ? (
97143
<FormField
98144
name="slug"

src/app/api/links/[linkId]/route.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,23 @@ export async function GET(request: Request, { params }: { params: Promise<{ link
2222
return json(website);
2323
}
2424

25+
const utmField = z
26+
.string()
27+
.max(255)
28+
.transform(v => (v === '' ? null : v))
29+
.nullable()
30+
.optional();
31+
2532
export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
2633
const schema = z.object({
2734
name: z.string().optional(),
2835
url: z.string().optional(),
2936
slug: z.string().min(8).optional(),
37+
utmSource: utmField,
38+
utmMedium: utmField,
39+
utmCampaign: utmField,
40+
utmTerm: utmField,
41+
utmContent: utmField,
3042
});
3143

3244
const { auth, body, error } = await parseRequest(request, schema);
@@ -36,14 +48,23 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
3648
}
3749

3850
const { linkId } = await params;
39-
const { name, url, slug } = body;
51+
const { name, url, slug, utmSource, utmMedium, utmCampaign, utmTerm, utmContent } = body;
4052

4153
if (!(await canUpdateLink(auth, linkId))) {
4254
return unauthorized();
4355
}
4456

4557
try {
46-
const result = await updateLink(linkId, { name, url, slug });
58+
const result = await updateLink(linkId, {
59+
name,
60+
url,
61+
slug,
62+
utmSource,
63+
utmMedium,
64+
utmCampaign,
65+
utmTerm,
66+
utmContent,
67+
});
4768

4869
return Response.json(result);
4970
} catch (e: any) {

src/app/api/links/route.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,25 @@ export async function GET(request: Request) {
2626
return json(links);
2727
}
2828

29+
const utmField = z
30+
.string()
31+
.max(255)
32+
.transform(v => (v === '' ? null : v))
33+
.nullable()
34+
.optional();
35+
2936
export async function POST(request: Request) {
3037
const schema = z.object({
3138
name: z.string().max(100),
3239
url: z.string().max(500),
3340
slug: z.string().max(100),
3441
teamId: z.string().nullable().optional(),
3542
id: z.uuid().nullable().optional(),
43+
utmSource: utmField,
44+
utmMedium: utmField,
45+
utmCampaign: utmField,
46+
utmTerm: utmField,
47+
utmContent: utmField,
3648
});
3749

3850
const { auth, body, error } = await parseRequest(request, schema);
@@ -41,7 +53,8 @@ export async function POST(request: Request) {
4153
return error();
4254
}
4355

44-
const { id, name, url, slug, teamId } = body;
56+
const { id, name, url, slug, teamId, utmSource, utmMedium, utmCampaign, utmTerm, utmContent } =
57+
body;
4558

4659
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
4760
return unauthorized();
@@ -53,6 +66,11 @@ export async function POST(request: Request) {
5366
url,
5467
slug,
5568
teamId,
69+
utmSource,
70+
utmMedium,
71+
utmCampaign,
72+
utmTerm,
73+
utmContent,
5674
};
5775

5876
if (!teamId) {

src/lib/url.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,19 @@ export function isValidUrl(url: string) {
4747
return false;
4848
}
4949
}
50+
51+
export function appendQueryParams(
52+
url: string,
53+
params: Record<string, string | null | undefined>,
54+
): string {
55+
const entries = Object.entries(params).filter(([, v]) => v != null && v !== '');
56+
if (entries.length === 0) return url;
57+
58+
try {
59+
const u = new URL(url);
60+
for (const [k, v] of entries) u.searchParams.set(k, v as string);
61+
return u.toString();
62+
} catch {
63+
return url;
64+
}
65+
}

src/queries/prisma/link.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Prisma } from '@/generated/prisma/client';
22
import prisma from '@/lib/prisma';
3+
import redis from '@/lib/redis';
34
import { sanitizeSortFilters } from '@/lib/sort';
45
import type { QueryFilters } from '@/lib/types';
56

@@ -58,13 +59,50 @@ export async function getTeamLinks(teamId: string, filters?: QueryFilters) {
5859
}
5960

6061
export async function createLink(data: Prisma.LinkUncheckedCreateInput) {
61-
return prisma.client.link.create({ data });
62+
const result = await prisma.client.link.create({ data });
63+
64+
// Defensive: a slug may be reused after a hard-delete and the redirect cache
65+
// for that slug can still hold the old link's URL for up to 24h.
66+
if (redis.enabled && result.slug) {
67+
await redis.client.del(`link:${result.slug}`);
68+
}
69+
70+
return result;
6271
}
6372

6473
export async function updateLink(linkId: string, data: any) {
65-
return prisma.client.link.update({ where: { id: linkId }, data });
74+
const before = redis.enabled
75+
? await prisma.client.link.findUnique({
76+
where: { id: linkId },
77+
select: { slug: true },
78+
})
79+
: null;
80+
81+
const result = await prisma.client.link.update({ where: { id: linkId }, data });
82+
83+
if (redis.enabled) {
84+
if (before?.slug) await redis.client.del(`link:${before.slug}`);
85+
if (result.slug && result.slug !== before?.slug) {
86+
await redis.client.del(`link:${result.slug}`);
87+
}
88+
}
89+
90+
return result;
6691
}
6792

6893
export async function deleteLink(linkId: string) {
69-
return prisma.client.link.delete({ where: { id: linkId } });
94+
const before = redis.enabled
95+
? await prisma.client.link.findUnique({
96+
where: { id: linkId },
97+
select: { slug: true },
98+
})
99+
: null;
100+
101+
const result = await prisma.client.link.delete({ where: { id: linkId } });
102+
103+
if (redis.enabled && before?.slug) {
104+
await redis.client.del(`link:${before.slug}`);
105+
}
106+
107+
return result;
70108
}

0 commit comments

Comments
 (0)