+
diff --git a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
index f600605c4..abe43ebcd 100644
--- a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
+++ b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
@@ -1,4 +1,4 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react';
import { ButtonContainer } from '@/components/button-container';
@@ -7,8 +7,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
import { VerifyFaq } from '@/components/onboarding/verify-faq';
import { LinkButton } from '@/components/ui/button';
+import { useEffect } from 'react';
+import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils';
+import { op } from '@/utils/op';
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
@@ -34,15 +37,35 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
+ const queryClient = useQueryClient();
const { data: events } = useQuery(
trpc.event.events.queryOptions(
{ projectId },
{
- refetchInterval: 2500,
+ // The live websocket below flips the verifier instantly; this poll is
+ // only a fallback for when the socket can't connect.
+ refetchInterval: 10_000,
}
)
);
+ // Refetch the event list the moment an event arrives instead of waiting for
+ // the next poll — same channel the in-app live event feed uses.
+ useWS(`/live/events/${projectId}`, () => {
+ queryClient.invalidateQueries(
+ trpc.event.events.queryFilter({ projectId })
+ );
+ });
const isVerified = events?.data && events.data.length > 0;
+
+ useEffect(() => {
+ op.track('onboarding_verify_viewed', { projectId });
+ }, [projectId]);
+
+ useEffect(() => {
+ if (isVerified) {
+ op.track('onboarding_first_event_verified', { projectId });
+ }
+ }, [isVerified, projectId]);
const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId })
);
diff --git a/apps/start/src/routes/_steps.onboarding.project.tsx b/apps/start/src/routes/_steps.onboarding.project.tsx
index a6e616259..108617504 100644
--- a/apps/start/src/routes/_steps.onboarding.project.tsx
+++ b/apps/start/src/routes/_steps.onboarding.project.tsx
@@ -27,6 +27,7 @@ import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
+import { op } from '@/utils/op';
const validateSearch = z.object({
inviteId: z.string().optional(),
@@ -74,6 +75,7 @@ function Component() {
trpc.onboarding.project.mutationOptions({
onError: handleError,
onSuccess(res) {
+ op.track('onboarding_project_created', { projectId: res.projectId });
queryClient.invalidateQueries(trpc.organization.list.queryFilter());
setSecret(res.secret);
navigate({
diff --git a/apps/worker/src/jobs/cron.data-health.ts b/apps/worker/src/jobs/cron.data-health.ts
index 2dc9c885d..7ca23ece5 100644
--- a/apps/worker/src/jobs/cron.data-health.ts
+++ b/apps/worker/src/jobs/cron.data-health.ts
@@ -65,7 +65,9 @@ export async function dataHealthCronJob() {
organizationId: true,
noDataNotifiedAt: true,
dataStoppedNotifiedAt: true,
- organization: { select: { id: true, subscriptionState: true } },
+ organization: {
+ select: { id: true, subscriptionState: true, onboarding: true },
+ },
},
});
@@ -81,10 +83,12 @@ export async function dataHealthCronJob() {
if (!lastEventAt) {
// Never received an event. One notice per project, after the grace
- // period. (The onboarding drip already nudges brand-new orgs, so this
- // mainly catches additional projects and broken installs.)
+ // period. Orgs still in the onboarding email drip are excluded — its
+ // day-2/6 emails already carry "stuck on the install?" variants, so this
+ // notice targets post-onboarding orgs and additional projects.
+ const inOnboardingDrip = project.organization.onboarding !== 'completed';
const oldEnough = now - project.createdAt.getTime() > NO_DATA_AFTER_MS;
- if (oldEnough && !project.noDataNotifiedAt) {
+ if (oldEnough && !project.noDataNotifiedAt && !inOnboardingDrip) {
const entry = byOrg.get(project.organizationId) ?? {
organizationId: project.organizationId,
noData: [],
diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts
index 0cac12864..62e4b8191 100644
--- a/apps/worker/src/jobs/events.incoming-event.ts
+++ b/apps/worker/src/jobs/events.incoming-event.ts
@@ -4,6 +4,7 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import {
checkNotificationRulesForEvent,
createEvent,
+ db,
getProjectByIdCached,
matchEvent,
sessionBuffer,
@@ -35,6 +36,24 @@ async function isEventExcludedByProjectFilter(
return eventExcludeFilters.some((filter) => matchEvent(payload, filter));
}
+/**
+ * Records the project's first-ever event timestamp exactly once. The cached
+ * project read makes this a no-op on every event after the first; the
+ * conditional update keeps concurrent workers idempotent.
+ */
+async function markFirstEvent(projectId: string, logger: ILogger) {
+ const project = await getProjectByIdCached(projectId);
+ if (!project || project.firstEventAt) {
+ return;
+ }
+ await db.project.updateMany({
+ where: { id: projectId, firstEventAt: null },
+ data: { firstEventAt: new Date() },
+ });
+ await getProjectByIdCached.clear(projectId);
+ logger.info({ projectId }, 'Project received its first event');
+}
+
async function createEventAndNotify(
payload: IServiceCreateEventPayload,
logger: ILogger,
@@ -53,6 +72,7 @@ async function createEventAndNotify(
const [event] = await Promise.all([
createEvent(payload),
checkNotificationRulesForEvent(payload).catch(() => null),
+ markFirstEvent(projectId, logger).catch(() => null),
]);
return event;
}
diff --git a/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql
new file mode 100644
index 000000000..bc7169354
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql
@@ -0,0 +1,4 @@
+-- Set once by the worker when a project's first event arrives. Powers the
+-- activation checklist and onboarding verification.
+ALTER TABLE "projects"
+ ADD COLUMN "firstEventAt" TIMESTAMP(3);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 3da985e29..d5a69d0e6 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -254,6 +254,9 @@ model Project {
allowUnsafeRevenueTracking Boolean @default(false)
/// [IPrismaProjectFilters]
filters Json @default("[]")
+ // Set once when the project's first event arrives (activation checklist,
+ // onboarding). Never updated after that.
+ firstEventAt DateTime?
// Data-health notice markers set by the dataHealth cron. `noDataNotifiedAt`:
// told the org this project never received events. `dataStoppedNotifiedAt`:
// told them the event flow stalled — compared against the last event time, so
diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx
index fc4d9e972..60a5ea258 100644
--- a/packages/email/src/emails/index.tsx
+++ b/packages/email/src/emails/index.tsx
@@ -76,11 +76,15 @@ export const templates = {
: 'Your OpenPanel trial ends soon',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
+ // Without a category these bypassed unsubscribe entirely (no suppression
+ // check, no List-Unsubscribe header).
+ category: 'onboarding' as const,
},
'onboarding-trial-ended': {
subject: () => 'Your trial ended, dashboard is locked',
Component: OnboardingTrialEnded,
schema: zOnboardingTrialEnded,
+ category: 'onboarding' as const,
},
'weekly-digest': {
subject: (data: z.infer
) =>
diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts
index 34819c733..9b65827aa 100644
--- a/packages/trpc/src/routers/project.ts
+++ b/packages/trpc/src/routers/project.ts
@@ -38,6 +38,44 @@ export const projectRouter = createTRPCRouter({
return getProjectWithClients(projectId);
}),
+ // Powers the activation checklist on the project overview: has the project
+ // received data, built a report, and invited a teammate yet?
+ activationStatus: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ })
+ )
+ .query(async ({ input: { projectId }, ctx }) => {
+ const access = await getProjectAccess({
+ userId: ctx.session.userId,
+ projectId,
+ });
+
+ if (!access) {
+ throw new TRPCForbiddenError('You do not have access to this project');
+ }
+
+ const project = await db.project.findUniqueOrThrow({
+ where: { id: projectId },
+ select: { firstEventAt: true, organizationId: true, createdAt: true },
+ });
+
+ const [reportCount, memberCount] = await Promise.all([
+ db.report.count({ where: { projectId } }),
+ db.member.count({
+ where: { organizationId: project.organizationId },
+ }),
+ ]);
+
+ return {
+ firstEventAt: project.firstEventAt,
+ projectCreatedAt: project.createdAt,
+ hasReport: reportCount > 0,
+ hasTeammate: memberCount > 1,
+ };
+ }),
+
list: protectedProcedure
.input(
z.object({