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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ MINIO_USE_SSL="false"
FILES_BASE_URL="localhost:9000"
FILES_BUCKET="neuron"

# Bugsink
BUGSINK_SECRET_KEY= # Generate from ./gen-pass.sh 60
SENTRY_DSN= # This is generated by following http://localhost:8000/projects/1/sdk-setup/

# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
Expand Down
12 changes: 12 additions & 0 deletions docker-compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ services:
volumes:
- miniodata:/data

bugsink:
image: bugsink/bugsink:latest
restart: unless-stopped
environment:
SECRET_KEY: ${BUGSINK_SECRET_KEY}
CREATE_SUPERUSER: admin:admin
ports:
- "8000:8000"
volumes:
- bugsinkdata:/data

volumes:
pgdata:
redisdata:
miniodata:
bugsinkdata:
6 changes: 5 additions & 1 deletion gen-pass.sh
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
openssl rand -base64 24 | tr '+/' '-_'
#!/bin/sh
LENGTH="${1:-32}"
BYTES=$(( (LENGTH * 3 + 3) / 4 ))
openssl rand -base64 "$BYTES" | tr -d '\n' | tr '+/' '-_' | head -c "$LENGTH"
echo
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@react-email/components": "^1.0.8",
"@react-email/render": "^2.0.4",
"@react-email/tailwind": "^2.0.5",
"@sentry/nextjs": "^10.43.0",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/postcss": "^4.2.1",
"@tanstack/react-form": "^1.28.4",
Expand Down Expand Up @@ -130,7 +131,7 @@
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.30.3",
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"@types/react": "19.2.8",
Expand Down
1,551 changes: 1,503 additions & 48 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions src/components/report-issue-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import { FormInputField } from "@/components/form/FormInput";
import { FormTextareaField } from "@/components/form/FormTextarea";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FieldGroup } from "@/components/ui/field";
import { SubmitBugReportInput } from "@/models/api/bug-report";
import { clientApi } from "@/trpc/client";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";

type ReportIssueSchemaType = z.infer<typeof SubmitBugReportInput>;

export const ReportIssueDialog = NiceModal.create(() => {
const modal = useModal();

const form = useForm<ReportIssueSchemaType>({
resolver: zodResolver(SubmitBugReportInput),
defaultValues: {
title: "",
description: "",
email: "",
},
mode: "onSubmit",
reValidateMode: "onChange",
});

const submitBugReport = clientApi.bugReport.submit.useMutation({
onSuccess: () => {
toast.success("Issue reported successfully. Thank you!");
modal.hide();
form.reset();
},
onError: (error) => {
toast.error(`Failed to report issue: ${error.message}`);
},
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const onSubmit = (data: ReportIssueSchemaType) => {
submitBugReport.mutate(data);
};

return (
<Dialog
open={modal.visible}
onOpenChange={(open) => (open ? modal.show() : modal.hide())}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Report Issue</DialogTitle>
<DialogDescription>
Let us know about any issues you&apos;re experiencing.
</DialogDescription>
</DialogHeader>

<form
className="flex flex-col gap-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FieldGroup>
<FormInputField
control={form.control}
name="title"
label="Title"
placeholder="Brief description of the issue"
maxLength={200}
required
/>

<FormTextareaField
control={form.control}
name="description"
label="What happened?"
placeholder="Describe the issue in detail..."
rows={5}
required
/>

<FormInputField
control={form.control}
name="email"
label="Contact Email"
placeholder="your.email@example.com"
type="email"
/>
</FieldGroup>

<DialogFooter>
<DialogClose asChild>
<Button
variant="outline"
size="sm"
disabled={submitBugReport.isPending}
>
Cancel
</Button>
</DialogClose>
<Button
type="submit"
size="sm"
disabled={submitBugReport.isPending}
>
{submitBugReport.isPending ? "Submitting..." : "Submit"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
});
7 changes: 3 additions & 4 deletions src/components/settings/settings-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { forceLogout } from "@/lib/auth/logout";
import { useAuth } from "@/providers/client-auth-provider";
import NiceModal from "@ebay/nice-modal-react";
import { ReportIssueDialog } from "../report-issue-dialog";
import { SettingsDialog } from "./settings-dialog";

export function SettingsDropdown({
Expand Down Expand Up @@ -46,11 +47,9 @@ export function SettingsDropdown({
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem disabled>
<DropdownMenuItem onSelect={() => NiceModal.show(ReportIssueDialog)}>
<Flag />
<span>
Report a Bug <i>(Coming soon)</i>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should do "Report an Issue" maybe, otherwise great work on the frontend

</span>
<span>Report Issue</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout}>
<LogOut />
Expand Down
5 changes: 5 additions & 0 deletions src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const env = createEnv({
MINIO_USE_SSL: z.string().transform((val) => val === "true"),
FILES_BASE_URL: z.url(),
FILES_BUCKET: z.string(),

// Sentry / Bugsink
SENTRY_DSN: z.string().url(),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},

/**
Expand Down Expand Up @@ -58,6 +61,8 @@ export const env = createEnv({

FILES_BASE_URL: process.env.FILES_BASE_URL,
FILES_BUCKET: process.env.FILES_BUCKET,

SENTRY_DSN: process.env.SENTRY_DSN,
},

skipValidation: !!process.env.SKIP_ENV_VALIDATION,
Expand Down
16 changes: 9 additions & 7 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { env } from "@/env";
import { migrate } from "@/server/db/migrate";
import * as Sentry from "@sentry/nextjs";

/**
* Next.js instrumentation hook. Called exactly once when the server process
* starts (both in development and production). This is the right place to run
* one-time startup work like database migrations.
*
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
environment: env.NODE_ENV,
});
}
await migrate();
}
7 changes: 7 additions & 0 deletions src/models/api/bug-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const SubmitBugReportInput = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().min(1, "Description is required").max(5000),
email: z.string().email("Invalid email").optional().or(z.literal("")),
});
2 changes: 2 additions & 0 deletions src/server/api/root.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "server-only";
import { bugReportRouter } from "@/server/api/routers/bug-report-router";
import { classRouter } from "@/server/api/routers/class-router";
import { coverageRouter } from "@/server/api/routers/coverage-router";
import { logRouter } from "@/server/api/routers/log-router";
Expand All @@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
term: termRouter,
profile: profileRouter,
storage: storageRouter,
bugReport: bugReportRouter,
});

// export type definition of API
Expand Down
21 changes: 21 additions & 0 deletions src/server/api/routers/bug-report-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Sentry from "@sentry/nextjs";
import { SubmitBugReportInput } from "@/models/api/bug-report";
import { authorizedProcedure } from "@/server/api/procedures";
import { createTRPCRouter } from "@/server/api/trpc";

export const bugReportRouter = createTRPCRouter({
submit: authorizedProcedure()
.input(SubmitBugReportInput)
.mutation(async ({ input }) => {
Sentry.captureMessage(input.title, {
level: "info",
tags: {
type: "user_bug_report",
},
extra: {
description: input.description,
email: input.email || "not provided",
},
});
}),
});
5 changes: 2 additions & 3 deletions src/server/services/jobService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export class JobService implements IJobService {
}

async start(): Promise<void> {
if (this.env.NODE_ENV === "test") return;
if (sharedBossState.isStarted) return;

sharedBossState.startPromise ??= this.bootstrap().catch((error) => {
Expand Down Expand Up @@ -190,7 +189,7 @@ export class JobService implements IJobService {
queueName,
cron,
(data ?? null) as object | null,
scheduleOptions as any,
scheduleOptions,
);
this.trackRecurringQueue(jobName, queueName);
return null;
Expand Down Expand Up @@ -331,7 +330,7 @@ export class JobService implements IJobService {
if (!normalized) {
throw new Error("correlationId must be a non-empty string.");
}
return `${jobName}:${normalized}`;
return `${jobName}/${normalized}`;
}

private async ensureWorkerRegistered(
Expand Down
41 changes: 20 additions & 21 deletions src/test/integration/job-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,17 +273,13 @@ describe("JobService", () => {
expect(result).toBeNull();
});

it("rejects correlationId due to colon separator in queue name (known bug)", async () => {
// getQueueName() produces "jobs.integration-test:tenant-1" but pg-boss
// only allows alphanumeric, underscores, hyphens, periods, and slashes.
// The ":" separator is invalid. This test documents the current behavior.
await expect(
jobService.run(
"jobs.integration-test",
{ message: "cron-corr" },
{ cron: "0 * * * *", correlationId: "tenant-1" },
),
).rejects.toThrow(/Name can only contain/);
it("schedules cron job with correlationId", async () => {
const result = await jobService.run(
"jobs.integration-test",
{ message: "cron-corr" },
{ cron: "0 * * * *", correlationId: "tenant-1" },
);
expect(result).toBeNull();
});

it("rejects cron with runAt", async () => {
Expand Down Expand Up @@ -320,10 +316,13 @@ describe("JobService", () => {
).resolves.toBeUndefined();
});

it("unschedules by correlationId (no-ops when schedule does not exist)", async () => {
// unschedule with correlationId resolves even if the schedule was never
// created — pg-boss.unschedule does not validate queue name format the
// same way as getQueue/createQueue.
it("unschedules by correlationId", async () => {
await jobService.run(
"jobs.integration-test",
{ message: "corr-unsched" },
{ cron: "0 * * * *", correlationId: "tenant-2" },
);

await expect(
jobService.unschedule("jobs.integration-test", {
correlationId: "tenant-2",
Expand All @@ -332,9 +331,9 @@ describe("JobService", () => {
});

it("throws for unknown job name", async () => {
await expect(
jobService.unschedule("jobs.nonexistent"),
).rejects.toThrow("Unknown job name");
await expect(jobService.unschedule("jobs.nonexistent")).rejects.toThrow(
"Unknown job name",
);
});
});

Expand Down Expand Up @@ -366,9 +365,9 @@ describe("JobService", () => {

describe("error handling", () => {
it("throws for unknown job name on run()", async () => {
await expect(
jobService.run("jobs.does-not-exist"),
).rejects.toThrow("Unknown job name");
await expect(jobService.run("jobs.does-not-exist")).rejects.toThrow(
"Unknown job name",
);
});

it("throws for unknown job name on unschedule()", async () => {
Expand Down