feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
|
||||
* that lives independently of any single `shares` row. The alias belongs to the
|
||||
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
|
||||
* so the address survives deletion of its current target (it 404s until
|
||||
* retargeted) rather than disappearing with the page.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('share_aliases')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
|
||||
.addColumn('alias', 'varchar', (col) => col.notNull())
|
||||
// Nullable + SET NULL: the address outlives its target page.
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// The vanity name is unique within a workspace (mirrors shares.key scoping).
|
||||
await db.schema
|
||||
.createIndex('share_aliases_workspace_id_alias_unique')
|
||||
.on('share_aliases')
|
||||
.columns(['workspace_id', 'alias'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
// "Which alias targets this page?" lookup for the share modal.
|
||||
await db.schema
|
||||
.createIndex('share_aliases_page_id_idx')
|
||||
.on('share_aliases')
|
||||
.column('page_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('share_aliases').execute();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as migration from './20260626T130000-share-aliases';
|
||||
import type {
|
||||
InsertableShareAlias,
|
||||
ShareAlias,
|
||||
UpdatableShareAlias,
|
||||
} from '../types/entity.types';
|
||||
|
||||
/**
|
||||
* Sanity checks for the share_aliases migration + entity types. We don't run a
|
||||
* live Postgres here (that's the integration suite); instead we assert the
|
||||
* migration exposes the expected up/down contract and creates the table with
|
||||
* the unique (workspace_id, alias) constraint and the page_id index, and that
|
||||
* the generated entity types line up with the column set.
|
||||
*/
|
||||
describe('share-aliases migration', () => {
|
||||
it('exports up and down functions', () => {
|
||||
expect(typeof migration.up).toBe('function');
|
||||
expect(typeof migration.down).toBe('function');
|
||||
});
|
||||
|
||||
it('up creates the table, the unique index and the page_id index', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const tableBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
// addColumn/addConstraint/etc. are chainable no-ops.
|
||||
return () => tableBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const indexBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
return () => indexBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const schema = {
|
||||
createTable: (name: string) => {
|
||||
calls.push(`createTable:${name}`);
|
||||
return tableBuilder;
|
||||
},
|
||||
createIndex: (name: string) => {
|
||||
calls.push(`createIndex:${name}`);
|
||||
return indexBuilder;
|
||||
},
|
||||
};
|
||||
|
||||
await migration.up({ schema } as any);
|
||||
|
||||
expect(calls).toContain('createTable:share_aliases');
|
||||
expect(calls).toContain(
|
||||
'createIndex:share_aliases_workspace_id_alias_unique',
|
||||
);
|
||||
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
|
||||
});
|
||||
|
||||
it('down drops the table', async () => {
|
||||
const calls: string[] = [];
|
||||
const dropBuilder: any = { execute: async () => undefined };
|
||||
const schema = {
|
||||
dropTable: (name: string) => {
|
||||
calls.push(`dropTable:${name}`);
|
||||
return dropBuilder;
|
||||
},
|
||||
};
|
||||
await migration.down({ schema } as any);
|
||||
expect(calls).toContain('dropTable:share_aliases');
|
||||
});
|
||||
|
||||
it('entity types expose the alias columns', () => {
|
||||
// Compile-time + runtime sanity: a well-formed row/insert/update value.
|
||||
const row: ShareAlias = {
|
||||
id: 'a-1',
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const insert: InsertableShareAlias = {
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
};
|
||||
const update: UpdatableShareAlias = { pageId: null };
|
||||
|
||||
expect(row.alias).toBe('foo');
|
||||
expect(insert.workspaceId).toBe('ws-1');
|
||||
expect(update.pageId).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user