Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/components/RecordDetailModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,17 @@
>
<span class="row gap-2">
<AppIcon
:name="sourceTypeIcon(record.attributes.source)"
:name="sourceTypeIcon(record.attributes.sourceType)"
:size="15"
:style="{ color: 'var(--accent)', flex: 'none' }"
/>
<span class="mono" style="font-size: 12px; color: var(--ink-2)">
{{ formatSourceLabel(record.attributes.source) }}
{{
formatSourceLabel(
record.attributes.source,
record.attributes.sourceType,
)
}}
</span>
</span>
<AppBadge :tone="STATUS_TONE_MAP[record.attributes.status] ?? ''" dot>
Expand Down
55 changes: 41 additions & 14 deletions app/composables/useRecords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export type RecordAttributes = {
content: string;
sourceId: string | null;
source: string | null;
// Free text on the server (sources.type has no CHECK constraint), so the
// contract is `string | null`, not `SourceType | null` — a legacy value
// outside SOURCE_TYPES can arrive. Consumers (sourceTypeIcon) narrow with
// isSourceType at the point of use.
sourceType: string | null;
status: RecordStatus;
filePath: string | null;
tags: unknown;
Expand Down Expand Up @@ -181,29 +186,51 @@ export const STATUS_TONE_MAP: Record<string, BadgeTone> = {
error: "err",
};

export function sourceTypeIcon(source: string | null): string {
if (!source) {
return "zap";
}
const DEFAULT_SOURCE_ICON = "zap";
const UNKNOWN_SOURCE_LABEL = "unknown";

// Icon per canonical source type (`sources.type`). Record rows resolve their
// icon from the real type, not the free-text `source` display name (which never
// carried a reliable type prefix). Keyed by SourceType so the compiler flags a
// missing icon whenever a new source type is added to the shared contract.
const SOURCE_TYPE_ICONS: Record<SourceType, string> = {
webhook: "zap",
email: "mail",
stripe: "card",
github: "github",
zapier: "zap",
shortcuts: "plug",
};

if (source.startsWith("email/")) {
return "mail";
export function sourceTypeIcon(sourceType: string | null): string {
if (!sourceType || !isSourceType(sourceType)) {
return DEFAULT_SOURCE_ICON;
}

return "zap";
return SOURCE_TYPE_ICONS[sourceType];
}

export function formatSourceLabel(source: string | null): string {
if (!source) {
return "unknown";
export function formatSourceLabel(
source: string | null,
sourceType: string | null,
): string {
// Prefer the source's display name so two sources of the same type (e.g. two
// webhooks, "Prod deploys" and "Staging deploys") stay distinguishable — the
// real type is already conveyed by the icon. Fall back to the type name, then
// to "unknown", when no name is stored.
if (source) {
return source;
}

const slashIndex = source.indexOf("/");
if (slashIndex === -1) {
return source;
// Show whatever type the server resolved, even a legacy value outside the
// current SOURCE_TYPES set (sources.type is free text) — a real type name is
// a better label than "unknown". The output is escaped by Vue, so it needs
// no isSourceType guard here (the icon lookup still does).
if (sourceType) {
return sourceType;
}

return source.slice(slashIndex + 1).replaceAll("/", " · ");
return UNKNOWN_SOURCE_LABEL;
}

export function formatRelativeTime(isoString: string): string {
Expand Down
9 changes: 7 additions & 2 deletions app/pages/inbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
>
<span class="row gap-2" style="width: 120px">
<AppIcon
:name="sourceTypeIcon(record.attributes.source)"
:name="sourceTypeIcon(record.attributes.sourceType)"
:size="15"
:style="{ color: 'var(--accent)', flex: 'none' }"
/>
Expand All @@ -211,7 +211,12 @@
text-overflow: ellipsis;
"
>
{{ formatSourceLabel(record.attributes.source) }}
{{
formatSourceLabel(
record.attributes.source,
record.attributes.sourceType,
)
}}
</span>
</span>
<span
Expand Down
13 changes: 10 additions & 3 deletions server/api/records/[uuid].get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm";
import { and, eq, getTableColumns } from "drizzle-orm";
import { getDb } from "../../db";
import { records } from "../../db/schema";
import { records, sources } from "../../db/schema";
import { requireUser } from "../../utils/auth";
import { apiErrorHandler } from "../../utils/errors";
import { recordSerializer, type RecordApiResponse } from "../../utils/response";
Expand All @@ -16,8 +16,15 @@ export async function findRecordForUser(
userId: string,
) {
const rows = await db
.select()
.select({ ...getTableColumns(records), sourceType: sources.type })
.from(records)
// Scope the join to the same user so it can never surface another tenant's
// source type, even if a future write path sets sourceId without the
// ownership check that guards it today.
.leftJoin(
sources,
and(eq(records.sourceId, sources.uuid), eq(sources.userId, userId)),
)
.where(and(eq(records.uuid, uuid), eq(records.userId, userId)))
.limit(1);

Expand Down
21 changes: 15 additions & 6 deletions server/api/records/index.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
count,
desc,
eq,
getTableColumns,
ilike,
inArray,
or,
Expand Down Expand Up @@ -208,12 +209,20 @@ function fetchRecordsPage(
size: number,
filters: RecordFilters,
) {
return db
.select()
.from(records)
.where(buildFilterConditions(db, userId, cursor, filters))
.orderBy(desc(records.createdAt), desc(records.uuid))
.limit(size + 1);
return (
db
.select({ ...getTableColumns(records), sourceType: sources.type })
.from(records)
// Scope the join to the same user so it can never surface another tenant's
// source type (mirrors the self-contained scoping in sourceTypeCondition).
.leftJoin(
sources,
and(eq(records.sourceId, sources.uuid), eq(sources.userId, userId)),
)
.where(buildFilterConditions(db, userId, cursor, filters))
.orderBy(desc(records.createdAt), desc(records.uuid))
.limit(size + 1)
);
}

export default defineEventHandler(
Expand Down
4 changes: 4 additions & 0 deletions server/utils/openapi.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,10 @@
"content": { "type": "string" },
"sourceId": { "type": ["string", "null"], "format": "uuid" },
"source": { "type": ["string", "null"] },
"sourceType": {
"description": "Source type resolved by joining the record's source; canonical values are those of SourceType, but the underlying column is free text so a legacy value outside that set may be returned. Authoritative on the list and detail GET endpoints. On create/patch responses (which do not join sources) this is always null and should be treated as a write receipt, not the resolved type — re-read the record via GET to obtain it.",
"type": ["string", "null"]
},
"status": { "$ref": "#/components/schemas/RecordStatus" },
"filePath": { "type": ["string", "null"] },
"tags": {
Expand Down
13 changes: 12 additions & 1 deletion server/utils/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ type RecordAttributes = {
content: string;
sourceId: string | null;
source: string | null;
// The canonical source type (`sources.type`), resolved by joining
// `records.sourceId → sources.uuid`. Distinct from `source`, which stores the
// free-text display name. Null when a record has no source (direct API create)
// or is returned by an endpoint that does not join sources (create/patch);
// the list and detail GET endpoints populate the real value.
sourceType: string | null;
status: string;
filePath: string | null;
tags: unknown;
Expand All @@ -25,7 +31,11 @@ type RecordAttributes = {
errorMessage: string | null;
};

type RecordInput = RecordAttributes;
// Callers that do not join sources (create/patch/hooks) pass a plain record row
// without `sourceType`; the serializer defaults it to null for them.
type RecordInput = Omit<RecordAttributes, "sourceType"> & {
sourceType?: string | null;
};

type RecordResource = ApiResourceObject & {
type: "records";
Expand Down Expand Up @@ -121,6 +131,7 @@ export function recordSerializer(
content: record.content,
sourceId: record.sourceId,
source: record.source,
sourceType: record.sourceType ?? null,
status: record.status,
filePath: record.filePath,
tags: record.tags,
Expand Down
21 changes: 18 additions & 3 deletions tests/components/RecordDetailModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ function makeRecord(overrides: Record<string, unknown> = {}) {
userId: "user-1",
title: "Test Record",
content: "# Heading\n\nBody",
sourceId: null,
source: "webhook/github",
sourceId: "source-1",
source: "My GitHub hook",
sourceType: "github",
status: "synced",
filePath: "99-incoming/test.md",
tags: null,
Expand All @@ -39,7 +40,7 @@ const stubs = {
props: ["variant", "size", "icon"],
emits: ["click"],
},
AppIcon: { template: "<span />", props: ["name", "size"] },
AppIcon: { template: '<span :data-icon="name" />', props: ["name", "size"] },
AppBadge: {
template: '<span class="app-badge"><slot /></span>',
props: ["tone", "dot"],
Expand Down Expand Up @@ -83,6 +84,20 @@ describe("RecordDetailModal", () => {
expect(wrapper.find(".app-code-block").text()).toContain("Heading");
});

it("resolves the source icon from the real type and labels with the source name", () => {
const wrapper = mountModal();
expect(wrapper.find("[data-icon]").attributes("data-icon")).toBe("github");
expect(wrapper.text()).toContain("My GitHub hook");
});

it("falls back to the zap icon and stored name when the source type is unresolved", () => {
const wrapper = mountModal({
record: makeRecord({ sourceType: null, source: "Legacy hook" }),
});
expect(wrapper.find("[data-icon]").attributes("data-icon")).toBe("zap");
expect(wrapper.text()).toContain("Legacy hook");
});

it("shows a loading indicator while loading", () => {
const wrapper = mountModal({ record: null, isLoading: true });
expect(wrapper.text()).toContain("loading record");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exports[`RecordDetailModal > matches the snapshot for a loaded record 1`] = `
<div data-v-ee0655ff="" class="card" tabindex="-1" style="width: 640px; max-width: 100%; max-height: 85vh; overflow-y: auto; box-shadow: var(--sh-pop); padding: 24px; outline-color: none; outline-style: none; outline-width: initial;">
<div data-v-ee0655ff="" class="row between" style="margin-bottom: 18px;"><span data-v-ee0655ff="" class="kicker">record detail</span><button data-v-ee0655ff="" class="app-btn">close</button></div>
<h3 data-v-ee0655ff="" style="font-size: 18px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 14px;">Test Record</h3>
<div data-v-ee0655ff="" class="row wrap gap-3" style="align-items: center; margin-bottom: 18px;"><span data-v-ee0655ff="" class="row gap-2"><span data-v-ee0655ff="" style="color: var(--accent); flex-grow: 0; flex-shrink: 0; flex-basis: auto;"></span><span data-v-ee0655ff="" class="mono" style="font-size: 12px; color: var(--ink-2);">github</span></span><span data-v-ee0655ff="" class="app-badge">synced</span><span data-v-ee0655ff="" class="mono faint" style="font-size: 12px;">35d ago</span></div>
<div data-v-ee0655ff="" class="row wrap gap-3" style="align-items: center; margin-bottom: 18px;"><span data-v-ee0655ff="" class="row gap-2"><span data-v-ee0655ff="" data-icon="github" style="color: var(--accent); flex-grow: 0; flex-shrink: 0; flex-basis: auto;"></span><span data-v-ee0655ff="" class="mono" style="font-size: 12px; color: var(--ink-2);">My GitHub hook</span></span><span data-v-ee0655ff="" class="app-badge">synced</span><span data-v-ee0655ff="" class="mono faint" style="font-size: 12px;">35d ago</span></div>
<dl data-v-ee0655ff="" class="detail-grid" style="margin-bottom: 18px;">
<dt data-v-ee0655ff="" class="kicker">file</dt>
<dd data-v-ee0655ff="" class="mono" style="font-size: 12px; color: var(--info); word-break: break-all;">99-incoming/test.md</dd>
Expand Down
62 changes: 47 additions & 15 deletions tests/composables/useRecords.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,34 +95,65 @@ describe("buildFetchUrl", () => {
});

describe("sourceTypeIcon", () => {
it("returns the mail icon for email sources", () => {
expect(sourceTypeIcon("email/inbound/gmail")).toBe("mail");
it("returns the mail icon for the email type", () => {
expect(sourceTypeIcon("email")).toBe("mail");
});

it("returns the zap icon for non-email sources", () => {
expect(sourceTypeIcon("webhook/github")).toBe("zap");
it("returns the card icon for the stripe type", () => {
expect(sourceTypeIcon("stripe")).toBe("card");
});

it("returns the zap icon for a null source", () => {
it("returns the github icon for the github type", () => {
expect(sourceTypeIcon("github")).toBe("github");
});

it("returns the plug icon for the shortcuts type", () => {
expect(sourceTypeIcon("shortcuts")).toBe("plug");
});

it("returns the zap icon for the webhook and zapier types", () => {
expect(sourceTypeIcon("webhook")).toBe("zap");
expect(sourceTypeIcon("zapier")).toBe("zap");
});

it("returns the zap icon for a null type", () => {
expect(sourceTypeIcon(null)).toBe("zap");
});

it("returns the zap icon for an unrecognized type", () => {
expect(sourceTypeIcon("mystery")).toBe("zap");
});
});

describe("formatSourceLabel", () => {
it("returns 'unknown' for a null source", () => {
expect(formatSourceLabel(null)).toBe("unknown");
it("prefers the source display name so same-type sources stay distinct", () => {
expect(formatSourceLabel("Prod deploys", "github")).toBe("Prod deploys");
expect(formatSourceLabel("Staging deploys", "github")).toBe(
"Staging deploys",
);
});

it("returns the source unchanged when it has no slash", () => {
expect(formatSourceLabel("webhook")).toBe("webhook");
it("falls back to the resolved type name when no source name is stored", () => {
expect(formatSourceLabel(null, "webhook")).toBe("webhook");
expect(formatSourceLabel(null, "email")).toBe("email");
});

it("drops the type prefix for a single-slash source", () => {
expect(formatSourceLabel("webhook/github")).toBe("github");
it("shows a legacy type name outside the current set rather than 'unknown'", () => {
expect(formatSourceLabel(null, "rss")).toBe("rss");
});

it("joins remaining segments with a middot for a multi-slash source", () => {
expect(formatSourceLabel("email/inbound/gmail")).toBe("inbound · gmail");
it("returns 'unknown' only when neither a source name nor a type is present", () => {
expect(formatSourceLabel(null, null)).toBe("unknown");
});

// Deliberate: a legacy `type/`-prefixed source name renders verbatim rather
// than being split on `/`. Re-adding slash-stripping would reintroduce the
// fragile prefix-parsing this change removed; the icon now conveys the type,
// and the stored name is shown honestly. Pinned so the behavior stays chosen.
it("renders a legacy prefixed source name verbatim (no slash-stripping)", () => {
expect(formatSourceLabel("webhook/github", "webhook")).toBe(
"webhook/github",
);
});
});

Expand Down Expand Up @@ -255,8 +286,9 @@ function makeRecordResource(uuid: string): RecordResource {
userId: "user-1",
title: `Record ${uuid}`,
content: "content",
sourceId: null,
source: "webhook/github",
sourceId: "source-1",
source: "My GitHub hook",
sourceType: "github",
status: "synced",
filePath: null,
tags: null,
Expand Down
Loading