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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ DATABASE_URL="postgresql://postgres:password@localhost:5433/pdr_ai_v2"
# SUPPORT_RESPONSE_TIME="Within 1 business day"
# SUPPORT_ISSUES_URL="https://github.com/Deodat-Lawson/LaunchStack/issues"

# ── In-app feedback (bug reports + feature requests) ────────────────
# Who triages: the operators' account emails, comma/space/semicolon
# separated, matched case-insensitively. Unset keeps the feature OFF (no
# report button, and the API refuses new reports) — nobody would read them.
# Accounts here are not email-verified, so create your own account before
# listing its address.
# FEEDBACK_ADMIN_EMAILS="ops@example.com, founder@example.com"
# Optional Slack or Discord incoming webhook told about each new report: one
# line with the kind, severity, title and a link to it in Triage — never the
# body, page context or screenshots.
# FEEDBACK_WEBHOOK_URL="https://hooks.slack.com/services/…"

# Extra hostnames next/image may load from, comma-separated. Any *.ufs.sh
# (UploadThing) and your S3 endpoint are allowed automatically. Build-time:
# next/image validates against the compiled list, so changing this needs a
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# /api/health and feedback reports name the build by its commit.
build-args: |
GIT_COMMIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=source
cache-to: type=gha,scope=source,mode=max

Expand Down
7 changes: 7 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,11 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# The commit this image was built from, for /api/health's `version` and the
# app version stamped on feedback reports. CI passes `github.sha`; a local
# build without it says "unknown", as the health route always has. Declared
# last so a new commit invalidates no earlier layer.
ARG GIT_COMMIT_SHA=unknown
ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA

CMD ["node", "server.js"]
95 changes: 95 additions & 0 deletions apps/web/__tests__/api/feedback/inbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* The workspace inbox keeps feedback rows ("the team replied to your
* report") for the person who filed the report, and only while it exists —
* before, every unknown target type was read as a chat id and dropped.
*/

jest.mock("~/server/db", () => ({
db: jest.requireActual("../../helpers/recording-db").recordingDb.db,
}));
jest.mock("~/lib/require-workspace-context", () => ({
requireWorkspaceContext: jest.fn(),
}));
jest.mock("~/server/sessions/access", () => ({
readableSessionIds: jest.fn(async () => new Set<string>()),
}));

import { document } from "@launchstack/store/schema";
import { GET } from "~/app/api/workspace/notifications/route";
import { requireWorkspaceContext } from "~/lib/require-workspace-context";
import { feedbackReports, workspaceNotifications } from "~/server/db/schema";

import { fakeDb } from "../../helpers/fake-db";
import { recordingDb } from "../../helpers/recording-db";
import { makeWorkspaceContext } from "../../helpers/workspace-context";

const ME = makeWorkspaceContext({ role: "member", authUserId: "auth-ada", companyId: 5n });

function row(over: Partial<Record<string, unknown>> & { id: number }) {
return {
companyId: 5n,
recipientUserId: "auth-ada",
actorUserId: "auth-ops",
kind: "feedback",
targetType: "feedback",
targetId: "42",
targetLabel: "Upload fails",
detail: { event: "reply" },
createdAt: new Date("2026-10-25T12:00:00Z"),
readAt: null,
...over,
};
}

beforeEach(() => {
recordingDb.reset();
jest.clearAllMocks();
jest.mocked(requireWorkspaceContext).mockResolvedValue({ success: true, data: ME });
});

it("keeps feedback rows for reports the reader filed, and drops the rest", async () => {
fakeDb.onSelect(
workspaceNotifications,
[
row({ id: 4, detail: { event: "status", status: "done" } }),
row({ id: 3, targetId: "43", targetLabel: "Gone" }),
row({ id: 2, kind: "shared", targetType: "document", targetId: "7" }),
row({ id: 1, kind: "mystery", targetType: "widget", targetId: "1" }),
],
[{ n: 4 }]
);
fakeDb.onSelect(feedbackReports, [{ id: 42 }]);
fakeDb.onSelect(document, [{ id: 7 }]);

const res = await GET(new Request("http://localhost/api/workspace/notifications"));

expect(res.status).toBe(200);
const body = (await res.json()) as {
entries: { id: number; targetType: string; targetId: string; detail: unknown }[];
unread: number;
};
expect(body.entries.map(e => [e.id, e.targetType, e.targetId])).toEqual([
[4, "feedback", "42"],
[2, "document", "7"],
]);
expect(body.entries[0]!.detail).toEqual({ event: "status", status: "done" });
expect(body.unread).toBe(2);

// The report check names the reader as its reporter.
const [where] = recordingDb.wheres(feedbackReports);
expect(where!.sql).toMatch(/"reporter_user_id" = \$\d/);
expect(where!.params).toEqual(expect.arrayContaining(["auth-ada", 42, 43]));
});

it("asks nothing about feedback when the inbox holds none", async () => {
fakeDb.onSelect(
workspaceNotifications,
[row({ id: 2, kind: "shared", targetType: "document", targetId: "7" })],
[{ n: 1 }]
);
fakeDb.onSelect(document, [{ id: 7 }]);

await GET(new Request("http://localhost/api/workspace/notifications"));

expect(recordingDb.wheres(feedbackReports)).toHaveLength(0);
});
Loading
Loading