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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ jobs:
- name: Test workspace tooling
run: node --test scripts/bun-scripts.test.mjs scripts/check-tauri-plugin-versions.test.mjs

- name: Check email library
run: |
bun run emails:check
bun test scripts/emails scripts/loops/profile.test.ts scripts/loops/lifecycle.test.ts scripts/loops/watchdog.test.ts
bun run tsc --noEmit --strict --jsx react-jsx --allowImportingTsExtensions --target ESNext --module ESNext --moduleResolution bundler --resolveJsonModule --types bun --skipLibCheck scripts/loops/*.ts scripts/emails/*.ts emails/*.ts emails/marketing/*.ts

- name: Test web React compatibility
run: >-
bun run --cwd apps/web test
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/loops-safety.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Loops delivery safety

on:
schedule:
- cron: "2-57/5 * * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: loops-delivery-safety
cancel-in-progress: false

jobs:
check:
if: vars.LOOPS_WATCHDOG_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.4.0"
- name: Hold lifecycle delivery if synchronization is unhealthy
env:
LOOPS_API_KEY: ${{ secrets.LOOPS_API_KEY }}
LOOPS_HEALTH_SECRET: ${{ secrets.LOOPS_HEALTH_SECRET }}
run: bun scripts/loops/watchdog.ts --apply
58 changes: 58 additions & 0 deletions apps/web/app/api/cron/sync-loops/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { timingSafeEqual } from "node:crypto";
import { readLoopsHealth } from "@cap/database/loops/health";
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpServerRequest,
} from "@effect/platform";
import { Effect, Layer, Schema } from "effect";
import { apiToHandler } from "@/lib/server";

class Api extends HttpApi.make("LoopsHealthApi").add(
HttpApiGroup.make("root").add(
HttpApiEndpoint.get("health")`/api/cron/sync-loops/health`
.addSuccess(
Schema.Struct({
healthy: Schema.Boolean,
checkedAt: Schema.String,
totalJobs: Schema.Number,
overdueJobs: Schema.Number,
failingJobs: Schema.Number,
}),
)
.addError(HttpApiError.Unauthorized)
.addError(HttpApiError.InternalServerError),
),
) {}

const ApiLive = HttpApiBuilder.api(Api).pipe(
Layer.provide(
HttpApiBuilder.group(Api, "root", (handlers) =>
handlers.handle("health", () =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const secret = process.env.LOOPS_HEALTH_SECRET;
if (!secret) return yield* new HttpApiError.InternalServerError();
const expected = Buffer.from(`Bearer ${secret}`);
const actual = Buffer.from(request.headers.authorization ?? "");
if (
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
)
return yield* new HttpApiError.Unauthorized();
return yield* Effect.tryPromise({
try: readLoopsHealth,
catch: () => new HttpApiError.InternalServerError(),
});
}),
),
),
),
);

export const GET = apiToHandler(ApiLive);
export const maxDuration = 30;
export const dynamic = "force-dynamic";
60 changes: 60 additions & 0 deletions apps/web/app/api/cron/sync-loops/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { timingSafeEqual } from "node:crypto";
import { runLoopsSync } from "@cap/database/loops/worker";
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpServerRequest,
} from "@effect/platform";
import { Effect, Layer, Schema } from "effect";
import { apiToHandler } from "@/lib/server";
import { customerCopy } from "../../../../../../emails/customer-copy";

class Api extends HttpApi.make("LoopsSyncApi").add(
HttpApiGroup.make("root").add(
HttpApiEndpoint.get("sync")`/api/cron/sync-loops`
.addSuccess(
Schema.Struct({
enabled: Schema.Boolean,
processed: Schema.Number,
failed: Schema.Number,
}),
)
.addError(HttpApiError.Unauthorized)
.addError(HttpApiError.InternalServerError),
),
) {}

const ApiLive = HttpApiBuilder.api(Api).pipe(
Layer.provide(
HttpApiBuilder.group(Api, "root", (handlers) =>
handlers.handle("sync", () =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const secret = process.env.CRON_SECRET;
if (!secret) return yield* new HttpApiError.InternalServerError();
const expected = Buffer.from(`Bearer ${secret}`);
const actual = Buffer.from(request.headers.authorization ?? "");
if (
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
)
return yield* new HttpApiError.Unauthorized();
const result = yield* Effect.tryPromise({
try: () => runLoopsSync(customerCopy),
catch: () => new HttpApiError.InternalServerError(),
});
if (result.failed)
return yield* new HttpApiError.InternalServerError();
return result;
}),
),
),
),
);

export const GET = apiToHandler(ApiLive);
export const maxDuration = 120;
export const dynamic = "force-dynamic";
3 changes: 3 additions & 0 deletions apps/web/app/api/invite/accept/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { nanoId } from "@cap/database/helpers";
import { enqueueLoopsSync } from "@cap/database/loops/queue";
import {
organizationInvites,
organizationMembers,
Expand Down Expand Up @@ -141,13 +142,15 @@ export async function POST(request: NextRequest) {
const userUpdate: Partial<typeof users.$inferInsert> = {
onboardingSteps,
activeOrganizationId: invite.organizationId,
marketingOrigin: "teammate",
};
if (!user.defaultOrgId) {
userUpdate.defaultOrgId = invite.organizationId;
}

await tx.update(users).set(userUpdate).where(eq(users.id, user.id));

await enqueueLoopsSync(tx, user.id, true);
await tx
.delete(organizationInvites)
.where(eq(organizationInvites.id, inviteId));
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from "@cap/database";
import { sendEmail } from "@cap/database/emails/config";
import { PaymentFailed } from "@cap/database/emails/payment-failed";
import { nanoId } from "@cap/database/helpers";
import { enqueueLoopsSync } from "@cap/database/loops/queue";
import {
developerCreditTransactions,
signedBaas,
Expand Down Expand Up @@ -608,6 +609,7 @@ export const POST = async (req: Request) => {
onboarding_completed_at: isOnBoarding ? new Date() : undefined,
})
.where(eq(users.id, dbUser.id));
await enqueueLoopsSync(db(), dbUser.id);

console.log("Successfully updated user in database");

Expand Down Expand Up @@ -767,6 +769,7 @@ export const POST = async (req: Request) => {
inviteQuota: inviteQuota,
})
.where(eq(users.id, dbUser.id));
await enqueueLoopsSync(db(), dbUser.id);

console.log(
"Successfully updated user in database with new invite quota:",
Expand Down Expand Up @@ -942,6 +945,7 @@ export const POST = async (req: Request) => {
inviteQuota: 1,
})
.where(eq(users.id, foundUserId));
await enqueueLoopsSync(db(), foundUserId);

console.log("User updated successfully", {
foundUserId,
Expand Down
7 changes: 6 additions & 1 deletion apps/web/lib/organization-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "server-only";

import { db } from "@cap/database";
import { nanoId } from "@cap/database/helpers";
import { enqueueLoopsSync } from "@cap/database/loops/queue";
import {
organizationInvites,
organizationMembers,
Expand Down Expand Up @@ -49,7 +50,9 @@ export async function provisionOrganizationInvitee({
const userId = existingUser?.id ?? User.UserId.make(nanoId());

if (existingUser) {
const userUpdate: Partial<typeof users.$inferInsert> = {};
const userUpdate: Partial<typeof users.$inferInsert> = {
marketingOrigin: "teammate",
};

if (!existingUser.name) {
userUpdate.name = getProvisionedUserName(normalizedEmail);
Expand All @@ -68,12 +71,14 @@ export async function provisionOrganizationInvitee({
await tx.insert(users).values({
id: userId,
email: normalizedEmail,
marketingOrigin: "teammate",
name: getProvisionedUserName(normalizedEmail),
activeOrganizationId: organizationId,
defaultOrgId: organizationId,
});
}

await enqueueLoopsSync(tx, userId);
const [existingMember] = await tx
.select({
id: organizationMembers.id,
Expand Down
1 change: 1 addition & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{ "path": "/api/cron/sync-loops", "schedule": "* * * * *" },
{
"path": "/api/cron/cleanup-agent-api",
"schedule": "17 3 * * *"
Expand Down
15 changes: 8 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions emails/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Working on Cap emails

- Start with `README.md`, `CATALOG.md` and `VOICE.md`. Use the catalogue to identify the email, flow, audience and actual send source before editing.
- Keep marketing content in `marketing/`, branding and sender defaults in `brand.ts`, customer-specific prose in `customer-copy.ts`, and ordering/delays in `flows.ts`. Do not add another copy of these definitions under `scripts/`.
- Keep email IDs and existing flow step keys stable. Declare variables and provide appropriate fallbacks. Preserve opt-outs, teammate exclusions, customer segmentation and imported-contact holds.
- Delivery uses custom MJML from `mjml.ts`. Generate archives with `bun run emails:export --output /absolute/path`, then upload the intended archive through Code in the Loops draft editor. The Loops API cannot read or update MJML emails; do not convert them back to native format to bypass this limitation. Reconcile intentional dashboard edits into source first.
- Regenerate `CATALOG.md` using `bun run emails:catalog`; never edit it by hand. Run `bun run emails:check`, scoped Biome, and relevant tests. After a remote draft update, run `bun run emails:check-loops --structure-only` and review every changed email in the browser, including metadata, variable fallbacks, loaded images and footer. API structure checks cannot establish MJML content parity.
- Keep the canonical Cap icon and vector wordmark together; never recreate the lettering with a font. Use `capGreeting` with `Hey,` as the complete fallback; never concatenate a space with an optional first name. Preserve the single address/unsubscribe footer without an opt-in explanation.
- Keep Resend application templates and sending behavior in their current locations; update `application.ts` when introducing or removing a send path. Listing a template does not establish that its handler is reachable or deployed.
- These commands do not authorize sending, publishing, activating flows, enrolling contacts or deploying production schema. Follow the user's current scope.
- Store only nonsecret resource IDs here. Keep credentials, contact exports and suppression registries outside Git.
Loading
Loading