Skip to content
Open
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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ bun run db:migrate
bun dev
```

On first run, approve Portless's local certificate prompt. The apps are then
available at `https://app.notra.localhost`, `https://notra.localhost`,
`https://api.notra.localhost`, and `https://docs.notra.localhost`. Portless
prefixes these hostnames automatically in linked Git worktrees.

Run a single app when needed:

```bash
Expand All @@ -108,6 +113,9 @@ bun dev --filter=web
bun dev --filter=docs
```

The React Email preview is available at `https://email.notra.localhost` when
running all development tasks, or on its own with `bun run email:dev`.

## QStash Local Workflows

If you're testing webhooks or workflows with QStash, set `NEXT_PUBLIC_APP_URL` to a public URL. `localhost` will not work for external callbacks.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,29 @@ bun install
bun run dev
```

Development servers run through [Portless](https://portless.sh/), which assigns
free internal ports and exposes stable HTTPS URLs:

| Service | Local URL |
| --- | --- |
| Dashboard | `https://app.notra.localhost` |
| Website | `https://notra.localhost` |
| API | `https://api.notra.localhost` |
| Docs | `https://docs.notra.localhost` |
| Email preview | `https://email.notra.localhost` |

The first run asks to trust Portless's local certificate authority. Linked Git
worktrees automatically receive a hostname prefix, so their servers do not
collide with the main checkout.

To run only the dashboard app:

```bash
bun run dev --filter=dashboard
```

Set `PORTLESS=0` to bypass the proxy and use the apps' fallback ports.

To build the dashboard app:

```bash
Expand Down
8 changes: 7 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
"name": "api",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun --watch src/index.ts",
"dev": "portless",
"dev:app": "NODE_ENV=development bun --watch src/index.ts",
"build": "tsup",
"start": "node dist/index.mjs",
"check-types": "bunx tsc --noEmit",
"knip": "knip"
},
"portless": {
"name": "api.notra",
"script": "dev:app"
},
"dependencies": {
"@contextcompany/otel": "^1.0.16",
"@hono/zod-openapi": "^1.2.2",
Expand Down Expand Up @@ -36,6 +41,7 @@
"@types/sanitize-html": "^2.16.0",
"@notra/typescript-config": "workspace:*",
"knip": "^6.12.1",
"portless": "^0.15.1",
"typescript": "^5"
}
}
8 changes: 7 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit",
Expand All @@ -15,6 +16,10 @@
"backfill:system-skills": "bun run scripts/backfill-system-skills.ts",
"backfill:collection-titles": "bun run scripts/backfill-collection-titles.ts"
},
"portless": {
"name": "app.notra",
"script": "dev:app"
},
"dependencies": {
"@ai-sdk/react": "3.0.208",
"@aws-sdk/client-s3": "^3.990.0",
Expand Down Expand Up @@ -110,6 +115,7 @@
"drizzle-kit": "^0.31.8",
"knip": "^6.12.1",
"postcss": "^8.5.8",
"portless": "^0.15.1",
"tailwindcss": "^4",
"typescript": "^5"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { upsertGitHubAppInstallation } from "@notra/ai/integrations/github";
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { ORPCError } from "@orpc/server";
import { type NextRequest, NextResponse } from "next/server";
import { assertOrganizationAccess } from "@/lib/auth/organization";
Expand All @@ -14,7 +15,10 @@ interface GitHubAppInstallState {

export async function GET(request: NextRequest) {
const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL ??
"";

try {
const { searchParams } = new URL(request.url);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { ORPCError } from "@orpc/server";
import { type NextRequest, NextResponse } from "next/server";
import { assertOrganizationAccess } from "@/lib/auth/organization";
Expand All @@ -7,7 +8,10 @@ import { linearAuthorizeQuerySchema } from "@/schemas/linear";

export async function GET(request: NextRequest) {
const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL ??
"";

try {
const { searchParams } = new URL(request.url);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getLinearIntegrationsByOrganization,
} from "@notra/ai/integrations/linear";
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { ORPCError } from "@orpc/server";
import { type NextRequest, NextResponse } from "next/server";
import { assertOrganizationAccess } from "@/lib/auth/organization";
Expand All @@ -17,7 +18,10 @@ import { buildCallbackUrl } from "@/utils/build-callback-url";

export async function GET(request: NextRequest) {
const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL ??
"";

try {
const { searchParams } = new URL(request.url);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encryptToken } from "@notra/ai/crypto/token-encryption";
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { db } from "@notra/db/drizzle";
import { connectedSocialAccounts } from "@notra/db/schema";
import { and, eq } from "drizzle-orm";
Expand Down Expand Up @@ -33,7 +34,10 @@ interface OAuthState {

export async function GET(request: NextRequest) {
const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL ??
"";

try {
const { searchParams } = new URL(request.url);
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/app/api/workflows/event/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FEATURES } from "@notra/ai/billing/features";
import { getGitHubToolRepositoryContextByIntegrationId } from "@notra/ai/integrations/github";
import { getBaseUrl } from "@notra/ai/qstash/triggers";
import { getValidToneProfile } from "@notra/ai/schemas/tone";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { db } from "@notra/db/drizzle";
import type { PostSourceMetadata } from "@notra/db/schema";
import {
Expand Down Expand Up @@ -767,7 +768,9 @@ export const { POST } = serve<EventWorkflowPayload>(
if (notificationData.enabled && notificationData.ownerEmails.length > 0) {
await context.run("send-notification-emails", async () => {
const baseUrl =
process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
"https://app.usenotra.com";
const contentOverviewLink = `${baseUrl}/${notificationData.organizationSlug}/content`;
const createdContent = createdPosts.map((createdPost) => ({
title: createdPost.title,
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/app/api/workflows/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getGitHubToolRepositoryContextByIntegrationId } from "@notra/ai/integra
import { getLinearToolContextByIntegrationId } from "@notra/ai/integrations/linear";
import { getBaseUrl, triggerScheduleNow } from "@notra/ai/qstash/triggers";
import { getValidToneProfile } from "@notra/ai/schemas/tone";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { db } from "@notra/db/drizzle";
import type { PostSourceMetadata } from "@notra/db/schema";
import {
Expand Down Expand Up @@ -1144,7 +1145,9 @@ export const { POST } = serve<ScheduleWorkflowPayload>(
if (notificationData.enabled && notificationData.ownerEmails.length > 0) {
await context.run("send-notification-emails", async () => {
const baseUrl =
process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com";
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
"https://app.usenotra.com";
const contentOverviewLink = `${baseUrl}/${notificationData.organizationSlug}/content`;
const createdContent = createdPosts.map((createdPost) => ({
title: createdPost.title,
Expand Down
6 changes: 4 additions & 2 deletions apps/dashboard/src/lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { oauthProvider } from "@better-auth/oauth-provider";
import { autumn } from "@notra/ai/billing/autumn";
import { FEATURES } from "@notra/ai/billing/features";
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { db } from "@notra/db/drizzle";
import { members, organizations, sessions } from "@notra/db/schema";
import type { CheckResponse } from "autumn-js";
Expand Down Expand Up @@ -143,6 +144,7 @@ function getTrustedOrigins() {
[
"http://localhost:3000",
"https://app.usenotra.com",
getPortlessUrl(),
process.env.BETTER_AUTH_URL,
process.env.NEXT_PUBLIC_APP_URL,
process.env.WORKFLOW_BASE_URL,
Expand Down Expand Up @@ -257,7 +259,7 @@ export const auth = betterAuth({
}),
organization({
sendInvitationEmail: async (data) => {
const inviteLink = `${process.env.BETTER_AUTH_URL}/invitation/${data.id}`;
const inviteLink = `${getPortlessUrl() ?? process.env.BETTER_AUTH_URL}/invitation/${data.id}`;
await sendInviteEmailAction({
inviteeEmail: data.email,
inviterName: data.inviter.user.name,
Expand Down Expand Up @@ -415,7 +417,7 @@ export const auth = betterAuth({
storeSessionInDatabase: true,
preserveSessionInDatabase: true,
},
baseURL: process.env.BETTER_AUTH_URL,
baseURL: getPortlessUrl() ?? process.env.BETTER_AUTH_URL,
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
Expand Down
15 changes: 11 additions & 4 deletions apps/dashboard/src/lib/email/send.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { AiCreditsDepletedEmail } from "@notra/email/emails/ai-credits-depleted";
import { FeedbackEmail } from "@notra/email/emails/feedback";
import { InviteUserEmail } from "@notra/email/emails/invite";
Expand Down Expand Up @@ -245,7 +246,9 @@ export async function sendScheduledContentFailedEmail(
subject,
}: SendScheduledContentFailedEmailProps
) {
const settingsLink = `${process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"}/${organizationSlug}/schedules`;
const appUrl =
getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const settingsLink = `${appUrl}/${organizationSlug}/schedules`;

return sendWithRetry(
resend,
Expand Down Expand Up @@ -279,7 +282,9 @@ export async function sendScheduledContentSkippedEmail(
subject,
}: SendScheduledContentSkippedEmailProps
) {
const settingsLink = `${process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"}/${organizationSlug}/schedules`;
const appUrl =
getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const settingsLink = `${appUrl}/${organizationSlug}/schedules`;

return sendWithRetry(
resend,
Expand Down Expand Up @@ -312,7 +317,8 @@ export async function sendAiCreditsDepletedEmail(
subject,
}: SendAiCreditsDepletedEmailProps
) {
const appUrl = process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const appUrl =
getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const creditsLink = `${appUrl}/${organizationSlug}/settings/credits`;
const idempotencyKey = createHash("sha256")
.update(
Expand Down Expand Up @@ -352,7 +358,8 @@ export async function sendWorkflowPausedEmail(
subject,
}: SendWorkflowPausedEmailProps
) {
const appUrl = process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const appUrl =
getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl();
const settingsLink = `${appUrl}/${organizationSlug}/automation/schedules`;
const idempotencyKey = createHash("sha256")
.update(
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/orpc/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getPortlessUrl } from "@notra/ai/utils/url";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
Expand All @@ -9,6 +10,7 @@ function getBaseUrl() {
}

return (
getPortlessUrl() ??
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.BETTER_AUTH_URL ??
"http://localhost:3000"
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/lib/orpc/routers/social-accounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redis } from "@notra/ai/utils/redis";
import { getPortlessUrl } from "@notra/ai/utils/url";
import { db } from "@notra/db/drizzle";
import { connectedSocialAccounts } from "@notra/db/schema";
import { and, eq } from "drizzle-orm";
Expand Down Expand Up @@ -127,7 +128,9 @@ export const socialAccountsRouter = {
}

const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL;
getPortlessUrl() ??
process.env.BETTER_AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL;
if (!baseUrl) {
throw badRequest("Application base URL is not configured");
}
Expand Down
10 changes: 8 additions & 2 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
"name": "docs",
"type": "module",
"scripts": {
"dev": "mint dev --port 3005 --no-open",
"dev": "portless",
"dev:app": "mint dev --port ${PORT:-3005} --no-open",
"open": "mint dev --port 3005",
"openapi:check": "mint openapi-check https://api.usenotra.com/openapi.json"
},
"portless": {
"name": "docs.notra",
"script": "dev:app"
},
"devDependencies": {
"mint": "^4.2.536"
"mint": "^4.2.536",
"portless": "^0.15.1"
}
}
8 changes: 7 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3001}",
"build": "next build",
"start": "next start",
"knip": "knip"
},
"portless": {
"name": "notra",
"script": "dev:app"
},
"dependencies": {
"@bydefault/vercel": "^0.1.4",
"@c15t/nextjs": "^2.0.4",
Expand Down Expand Up @@ -57,6 +62,7 @@
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"knip": "^6.12.1",
"portless": "^0.15.1",
"tailwindcss": "^4",
"typescript": "^5"
},
Expand Down
Loading
Loading