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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,17 @@ GOOGLE_CLIENT_SECRET=""
# ── Optional: operations ─────────────────────────────────────────────────────

# Shared cache. Without it each instance caches in its own memory, which is
# correct — just not shared.
# correct — just not shared. Recommended in production once website tracking is
# on: the compiled tracking config, the collector's rate limit and the hourly
# cap on contacts created from forms are all counted here, and per-instance
# counters let a multi-instance deployment exceed both.
# REDIS_URL="redis://localhost:6379"
# CACHE_TTL_MS="60000"

# Bearer token guarding POST /internal/sync/google, the Gmail/Calendar cron
# route. The route refuses to run without it. At least 16 characters.
# route, and POST /internal/tracking/retention, the nightly sweep that deletes
# tracked page views older than 90 days. Both refuse to run without it. At
# least 16 characters.
# CRON_SECRET=""

# Log every SQL statement Prisma runs, at debug level, without bound
Expand Down
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.5.0"
".": "1.5.1"
}
26 changes: 22 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,34 @@ jobs:
run: |
set -euo pipefail

stuck=$(gh pr list --repo "$GITHUB_REPOSITORY" --base main --state merged --label "autorelease: pending" --limit 1 --json number,title --jq '.[0] | select(.) | "#\(.number) \(.title)"')
candidates=$(gh pr list --repo "$GITHUB_REPOSITORY" --base main --state merged --label "autorelease: pending" --limit 20 --json number --jq '.[].number')

stuck=""

for pr in $candidates; do
state=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/labels" --jq 'map(.name) | if index("autorelease: tagged") then "tagged" elif index("autorelease: pending") then "pending" else "cleared" end')

if [ "$state" != "pending" ]; then
continue
fi

title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" --jq '.title')
stuck="${stuck}#${pr} ${title}"$'\n'
done

if [ -z "$stuck" ]; then
exit 0
fi

{
echo "**$stuck merged without a tag.** release-please aborts on it, so nothing releases and no release"
echo "pull request opens until it is cleared: create the tag and GitHub Release at that pull request's"
echo "merge commit, then swap its \`autorelease: pending\` label for \`autorelease: tagged\`."
echo "**A merged release pull request was never tagged.** release-please aborts on it, so nothing"
echo "releases and no release pull request opens until it is cleared: create the tag and GitHub"
echo "Release at that pull request's merge commit, then swap its \`autorelease: pending\` label for"
echo "\`autorelease: tagged\`."
echo
echo '```'
echo "$stuck"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

exit 1
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ context until you read them, and the rules in them are not optional.
| UI in `apps/app` or `packages/ui` | `docs/design.md` (below) |
| Deal amounts, totals, charts, exchange rates | `docs/currency.md` |
| The record sheet's Agent tab | `docs/agent-panel.md` |
| The tracking script, the collector, form submissions | `docs/tracking.md` |
| Running it locally, Google Cloud, DB commands, secrets | `docs/setup.md` |
| Anything that sends a telemetry event, or a new property on one | `docs/telemetry.md` |
| `.github/workflows`, versions, changelog, how a change reaches `release` | `CONTRIBUTING.md` |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.5.1](https://github.com/trycompai/crm/compare/v1.5.0...v1.5.1) (2026-08-08)


### Fixes

* **api:** warn when the deployed schema does not match schema.prisma ([#88](https://github.com/trycompai/crm/issues/88)) ([f445c68](https://github.com/trycompai/crm/commit/f445c68a815ad1635498591daa494d18d9508ccf))

## [1.5.0](https://github.com/trycompai/crm/compare/v1.4.0...v1.5.0) (2026-08-08)


Expand Down
21 changes: 20 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ integration tests. When you need to push past it — a WIP branch, a docker-less
you are deliberately pushing to ask about — `git push --no-verify` skips it, and `CRM_SKIP_HOOKS=1`
skips it for a whole shell.

**`test` runs one package at a time (`turbo run test --concurrency=1`), and that is not an
oversight.** `apps/api`, `apps/agent`, `packages/auth` and `packages/telemetry` all have real
integration tests and they all point at the *same* database, so running them at once lets one
package's fixtures land inside another package's assertions. The specs mutate global singletons —
the workspace `organization` row, `AppSetting`'s reporting currency, the exchange-rate table — and
none of that is namespaced per package. Left parallel it failed roughly three runs in four, on a
different test each time, which reads as "flaky tests" and trains everyone to hit re-run. Serial
costs about ten seconds. **Do not raise the concurrency without giving each package its own
database.**

A few things that trip people up:

- **The tRPC router type is generated, and committed.** If the app can't see a procedure you just
Expand Down Expand Up @@ -171,7 +181,7 @@ release workflow, which cannot wait on a run in another workflow.
A jam used to be silent — the workflow reported success while doing nothing, and the symptom was
merges landing with no release PR behind them. **The workflow now fails when a merged release PR
carries `autorelease: pending`**, which is the state every jam ends in, so the Actions tab tells you
within a minute of the merge that shipped it. Read the log rather than the status anyway. Two
within a minute of the merge that shipped it. Read the log rather than the status anyway. Three
failures have actually happened here:

- **`There are untagged, merged release PRs outstanding - aborting`.** A release PR was merged but
Expand All @@ -186,6 +196,15 @@ failures have actually happened here:
ever tagged automatically — `v1.0.0` through `v1.3.0` were all cut by hand. **A second package
will bring the Merge plugin back**, and with it this bug: give the packages components in the tag,
or check that a merged release PR still gets `autorelease: tagged`.
- **The guard failing on the release it just cut.** Every release run failed — `v1.4.0`, `v1.5.0`
and `v1.5.1` were all tagged correctly, and the run that tagged each one then reported it as
stuck. `gh pr list --label` reads the **search index, which is eventually consistent**, and
release-please swaps `autorelease: pending` for `autorelease: tagged` about a second before the
guard runs; the index still held the old label. A guard that cries wolf on every release is worse
than no guard, because the one real jam is indistinguishable from the noise. The search is now
only a prefilter: each candidate's labels are re-read through `gh api .../issues/N/labels`, which
is strongly consistent, and only a pull request that is still genuinely `pending` fails the run.
**Any label check written against `gh pr list --search`/`--label` needs the same treatment.**
- **`commit could not be parsed`, in bulk.** Non-conventional subjects reached `main`. They are not
errors, they are silently missing changelog lines. The squash-only merge policy and the
`conventional commit` check exist to stop this; if you see it again, one of the two has been
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { SettingsModule } from "./settings/settings.module";
import { SsoModule } from "./sso/sso.module";
import { SyncModule } from "./sync/sync.module";
import { TelemetryModule } from "./telemetry/telemetry.module";
import { TrackingModule } from "./tracking/tracking.module";
import { TrpcModule } from "./trpc/trpc.module";
import { UsersModule } from "./users/users.module";
import { WorkspaceModule } from "./workspace/workspace.module";
Expand Down Expand Up @@ -67,6 +68,7 @@ import { WorkspaceModule } from "./workspace/workspace.module";
SsoModule,
BackfillModule,
TelemetryModule,
TrackingModule,
],
})
export class AppModule {}
31 changes: 31 additions & 0 deletions apps/api/src/generated/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { setAutoCreateInput, suppressDomainInput, threadInput, calendarEventInpu
import { setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts";
import { setAgentModelInput, setResearchKeyInput } from "../settings/settings.contracts";
import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts";
import { trackingFlagInput, cookieLifetimeInput, addDomainInput, removeDomainInput, verifyInput, companyActivityInput, contactActivityInput } from "../tracking/tracking.contracts";
import { memberListInput, updateWorkspaceInput, setMemberRoleInput } from "../workspace/workspace.contracts";
import type { ActivitiesRouter } from "../activities/activities.router";
import type { AgentsRouter } from "../agent/agents.router";
Expand All @@ -41,6 +42,7 @@ import type { MicrosoftRouter } from "../microsoft/microsoft.router";
import type { SearchRouter } from "../search/search.router";
import type { SettingsRouter } from "../settings/settings.router";
import type { SsoRouter } from "../sso/sso.router";
import type { TrackingRouter } from "../tracking/tracking.router";
import type { UsersRouter } from "../users/users.router";
import type { WorkspaceRouter } from "../workspace/workspace.router";

Expand Down Expand Up @@ -383,6 +385,35 @@ const appRouter = t.router({
.input(deleteSsoProviderInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<SsoRouter["remove"]>>)
}),
tracking: t.router({
settings: publicProcedure
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["settings"]>>),
setFlag: publicProcedure
.input(trackingFlagInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["setFlag"]>>),
setCookieLifetime: publicProcedure
.input(cookieLifetimeInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["setCookieLifetime"]>>),
addDomain: publicProcedure
.input(addDomainInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["addDomain"]>>),
removeDomain: publicProcedure
.input(removeDomainInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["removeDomain"]>>),
rotateSiteId: publicProcedure
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["rotateSiteId"]>>),
verify: publicProcedure
.input(verifyInput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["verify"]>>),
sources: publicProcedure
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["sources"]>>),
companyActivity: publicProcedure
.input(companyActivityInput)
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["companyActivity"]>>),
contactActivity: publicProcedure
.input(contactActivityInput)
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<TrackingRouter["contactActivity"]>>)
}),
users: t.router({
me: publicProcedure
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited<ReturnType<UsersRouter["me"]>>),
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/telemetry/rollup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@crm/db";
import { RETIRED_OUTCOME } from "@crm/db/agent-tasks";
import { readAgentModel } from "@crm/db/settings";
import { CONTACT_CAP_REASON } from "@crm/db/tracking";
import { WORKSPACE_ID } from "@crm/db/workspace";
import {
bucket,
Expand Down Expand Up @@ -495,6 +496,30 @@ export class RollupService {
}),
]);

const [
trackingSite,
trackingDomains,
trackingViews,
trackingForms,
trackingContacts,
trackingCapped,
trackingPaused,
] = await Promise.all([
this.db.appSetting.count({ where: { trackingSiteId: { not: null } } }),
this.db.trackedDomain.count(),
this.db.trackedEvent.count({
where: { type: "page_view", occurredAt: { gte: since } },
}),
this.db.formSubmission.count({ where: { createdAt: { gte: since } } }),
this.db.contact.count({
where: { createdAt: { gte: since }, source: RecordSource.TRACKING },
}),
this.db.formSubmission.count({
where: { createdAt: { gte: since }, skipReason: CONTACT_CAP_REASON },
}),
this.db.appSetting.count({ where: { trackingPaused: true } }),
]);

const configured = syncs.reduce((sum, row) => sum + row._count._all, 0);

return {
Expand Down Expand Up @@ -528,6 +553,14 @@ export class RollupService {
Object.values(ActivityType),
),

cap_tracking: trackingSite > 0,
tracking_domains: bucket(trackingDomains),
tracking_page_views: trackingViews,
tracking_forms: trackingForms,
tracking_contacts_created: trackingContacts,
tracking_capped: trackingCapped,
tracking_paused: trackingPaused > 0,

mailbox_sync_configured: configured > 0,
mailbox_sync_status: merge(
syncs.map((row) => ({ key: row.status, count: row._count._all })),
Expand Down
133 changes: 133 additions & 0 deletions apps/api/src/tracking/tracking-config.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Db } from "@crm/db";
import { SETTINGS_ID } from "@crm/db/settings";
import {
configHash,
mintSiteId,
readTrackingConfig,
type TrackingConfig,
} from "@crm/db/tracking";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { InjectDatabase } from "../database/database.constants";

const CONFIG_TTL_MS = 5 * 60_000;

const CONFIG_KEY = "tracking:config";

export interface CompiledConfig {
config: TrackingConfig;
hash: string;
}

@Injectable()
export class TrackingConfigService {
private readonly logger = new Logger(TrackingConfigService.name);

private generation = 0;

constructor(
@InjectDatabase() private readonly db: Db,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}

async compiled(): Promise<CompiledConfig | null> {
const cached = await this.cache.get<CompiledConfig>(CONFIG_KEY);
if (cached) return cached;

const read = this.generation;
const config = await readTrackingConfig(this.db);
if (!config) return null;

const compiled = { config, hash: configHash(config) };

if (read === this.generation && (await this.current(compiled.hash))) {
await this.cache.set(CONFIG_KEY, compiled, CONFIG_TTL_MS);
}

return compiled;
}

private async current(hash: string): Promise<boolean> {
const row = await this.db.appSetting.findUnique({
where: { id: SETTINGS_ID },
select: { trackingConfigHash: true },
});

return row?.trackingConfigHash === hash;
}

async forSite(siteId: string): Promise<CompiledConfig | null> {
const compiled = await this.compiled();
return compiled?.config.siteId === siteId ? compiled : null;
}

async invalidate(): Promise<void> {
this.generation += 1;
const written = this.generation;

await this.cache.del(CONFIG_KEY);

const config = await readTrackingConfig(this.db);

if (!config) {
await this.db.appSetting.updateMany({
where: { id: SETTINGS_ID },
data: { trackingConfigHash: null },
});

return;
}

const hash = configHash(config);

await this.db.appSetting.update({
where: { id: SETTINGS_ID },
data: { trackingConfigHash: hash },
});

if (written !== this.generation) return;
if (!(await this.current(hash))) return;

await this.cache.set(CONFIG_KEY, { config, hash }, CONFIG_TTL_MS);
}

async ensureSiteId(): Promise<string> {
const existing = await this.db.appSetting.findUnique({
where: { id: SETTINGS_ID },
select: { trackingSiteId: true },
});

if (existing?.trackingSiteId) return existing.trackingSiteId;

const trackingSiteId = mintSiteId();

await this.db.appSetting.upsert({
where: { id: SETTINGS_ID },
create: { id: SETTINGS_ID, trackingSiteId },
update: { trackingSiteId },
});

await this.invalidate();

this.logger.log({ message: "Tracking site id minted" });

return trackingSiteId;
}

async rotateSiteId(): Promise<string> {
const trackingSiteId = mintSiteId();

await this.db.appSetting.upsert({
where: { id: SETTINGS_ID },
create: { id: SETTINGS_ID, trackingSiteId },
update: { trackingSiteId },
});

await this.invalidate();

this.logger.warn({ message: "Tracking site id rotated" });

return trackingSiteId;
}
}
Loading