Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e386c18
feat(observability): add local otel collector and backend tracing
insideaayush Feb 22, 2026
d9d7dd7
feat(integrations): add analytics, support widget, and error reporting
insideaayush Feb 22, 2026
89e9c59
feat: backend analytics + UI i18n + UI guide
insideaayush Feb 22, 2026
79ce652
docs: add production checklist and reading order
insideaayush Feb 22, 2026
4af28e0
feat(ui): add shadcn-ui primitives and Tailwind
insideaayush Feb 22, 2026
750bc16
feat: add jobs, email, uploads, and audit logs
insideaayush Feb 22, 2026
c9dc657
feat(tenancy): enforce single-member personal workspaces
insideaayush Feb 22, 2026
76a683e
db: enforce team org owner
insideaayush Feb 22, 2026
3ab3625
api: add org RBAC roles
insideaayush Feb 22, 2026
47b6a8f
orgs: add team org management
insideaayush Feb 22, 2026
2b5361c
ops: add local e2e smoke test
insideaayush Feb 22, 2026
ac96c7e
smoke: fix empty-args parsing
insideaayush Feb 22, 2026
c48c45d
telemetry: fix resource schema conflict
insideaayush Feb 22, 2026
e233101
smoke: use isolated db per run
insideaayush Feb 22, 2026
b6c47a4
db: add migrate CLI
insideaayush Feb 22, 2026
a170a73
smoke: wait for postgres stability
insideaayush Feb 22, 2026
e005cc6
smoke: fix port collisions and cleanup
insideaayush Feb 22, 2026
3ad020e
smoke: run compiled binaries for cleanup
insideaayush Feb 22, 2026
5522721
docs: add template gaps and upstreaming guide
insideaayush Feb 23, 2026
acf2338
frontend: make typecheck resilient to .next
insideaayush Feb 23, 2026
36fdd2e
frontend: fix lint on Node 19
insideaayush Feb 23, 2026
89442ff
chore: add .nvmrc for Node 20
insideaayush Feb 23, 2026
e5c053c
frontend: fix i18n build and lint script
insideaayush Feb 23, 2026
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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,32 @@ APP_VERSION=dev
PORT=8080
DATABASE_URL=postgres://postgres:postgres@localhost:5432/saas_core_template?sslmode=disable
REDIS_URL=redis://localhost:6379
OTEL_TRACES_EXPORTER=console
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
ERROR_REPORTING_PROVIDER=console
SENTRY_DSN=
SENTRY_ENVIRONMENT=development
ANALYTICS_PROVIDER=console
POSTHOG_PROJECT_KEY=
POSTHOG_HOST=https://app.posthog.com
EMAIL_PROVIDER=console
EMAIL_FROM=
RESEND_API_KEY=
JOBS_ENABLED=true
JOBS_WORKER_ID=local
JOBS_POLL_INTERVAL=1s
FILE_STORAGE_PROVIDER=disk
FILE_STORAGE_DISK_PATH=./backend/.data/uploads

# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_ANALYTICS_PROVIDER=console
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
NEXT_PUBLIC_SUPPORT_PROVIDER=none
NEXT_PUBLIC_CRISP_WEBSITE_ID=
NEXT_PUBLIC_ERROR_REPORTING_PROVIDER=console
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_ENVIRONMENT=development
NEXT_PUBLIC_APP_VERSION=dev
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ frontend/.next/
frontend/out/
frontend/node_modules/
*.tsbuildinfo
frontend/pnpm-lock.yaml

# Backend
backend/bin/
backend/api
backend/.data/

# Local infra
pgdata/
2 changes: 2 additions & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
20

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ This file defines non-negotiable engineering guardrails for humans and AI agents
- Cross-cutting:
- Re-run targeted searches for old identifiers and stale provider references.
- Verify no secrets were added to tracked files.
- Confirm managed integrations remain optional and local E2E works with console/noop defaults (telemetry, analytics, error reporting, support).

## Git and change hygiene

Expand Down
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
SHELL := /bin/sh

.PHONY: infra-up infra-down dev-api dev-ui test ci
.PHONY: infra-up infra-down dev-api dev-ui dev-worker migrate-up migrate-status test ci smoke-local

infra-up:
docker compose up -d postgres redis
docker compose up -d postgres redis otel-collector

infra-down:
docker compose down

dev-api:
cd backend && go run ./cmd/api

dev-worker:
cd backend && go run ./cmd/worker

dev-ui:
cd frontend && npm run dev

migrate-up:
cd backend && go run ./cmd/migrate up

migrate-status:
cd backend && go run ./cmd/migrate status

smoke-local:
bash scripts/smoke-local.sh $(SMOKE_ARGS)

test:
cd backend && go test ./...
cd frontend && npm run lint && npm run typecheck
Expand Down
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Production-shaped foundation for launching a startup SaaS baseline quickly.

## Stack
- Frontend: Next.js (TypeScript)
- UI: shadcn/ui (Tailwind + Radix)
- Backend: Go (`net/http`)
- Database: Postgres
- Cache: Redis (Upstash in cloud, local Redis in development)
Expand Down Expand Up @@ -43,6 +44,25 @@ Core variables:
- `APP_BASE_URL` (frontend URL used for checkout return paths)
- `APP_ENV` (`development` or `production`)
- `APP_VERSION` (`dev`, commit SHA, or release tag)
- `OTEL_SERVICE_NAME` (default `saas-core-template-backend`)
- `OTEL_TRACES_EXPORTER` (`console`, `otlp`, or `none`)
- `OTEL_EXPORTER_OTLP_ENDPOINT` (local collector default `http://localhost:4318`)
- `OTEL_EXPORTER_OTLP_HEADERS` (for managed OTLP auth, e.g. Grafana Cloud)
- `ERROR_REPORTING_PROVIDER` (`console`, `sentry`, or `none`)
- `SENTRY_DSN` (backend error reporting)
- `SENTRY_ENVIRONMENT` (defaults to empty)
- `ANALYTICS_PROVIDER` (`console`, `posthog`, or `none`)
- `POSTHOG_PROJECT_KEY`
- `POSTHOG_HOST`
- `EMAIL_PROVIDER` (`console`, `resend`, or `none`)
- `EMAIL_FROM`
- `RESEND_API_KEY`
- `JOBS_ENABLED` (worker toggle)
- `JOBS_WORKER_ID`
- `JOBS_POLL_INTERVAL`
- `FILE_STORAGE_PROVIDER` (`disk`, `s3`, or `none`)
- `FILE_STORAGE_DISK_PATH`
- `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE`
- `CLERK_SECRET_KEY`
- `CLERK_API_URL` (default `https://api.clerk.com`)
- `STRIPE_SECRET_KEY`
Expand All @@ -53,15 +73,35 @@ Core variables:
- Frontend
- `NEXT_PUBLIC_API_URL` (e.g. `http://localhost:8080`)
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- `NEXT_PUBLIC_ANALYTICS_PROVIDER` (`console`, `posthog`, or `none`)
- `NEXT_PUBLIC_POSTHOG_KEY`
- `NEXT_PUBLIC_POSTHOG_HOST`
- `NEXT_PUBLIC_SUPPORT_PROVIDER` (`crisp` or `none`)
- `NEXT_PUBLIC_CRISP_WEBSITE_ID`
- `NEXT_PUBLIC_ERROR_REPORTING_PROVIDER` (`console`, `sentry`, or `none`)
- `NEXT_PUBLIC_SENTRY_DSN`
- `NEXT_PUBLIC_SENTRY_ENVIRONMENT`
- Locale is stored in a `locale` cookie (supported: `en`, `es`)

## Database migrations

SQL migrations live in `backend/migrations/`.

Apply them with your preferred migration tool before using auth/billing endpoints.
Initial migration file:
Apply them before using auth/billing endpoints.

Recommended: run the built-in migration CLI (tracks applied migrations in `schema_migrations`):

```bash
make migrate-up
```

Initial migration files (applied in order):

- `backend/migrations/0001_identity_tenancy_billing.up.sql`
- `backend/migrations/0002_jobs_audit_files.up.sql`
- `backend/migrations/0003_personal_workspaces.up.sql`
- `backend/migrations/0004_team_owner_enforcement.up.sql`
- `backend/migrations/0005_org_invites.up.sql`

## Local development
Run infra first:
Expand All @@ -70,12 +110,34 @@ Run infra first:
make infra-up
```

This starts Postgres, Redis, and a local OpenTelemetry collector (for local tracing).

Optional: run a local end-to-end smoke test (infra + api + worker + ui):

```bash
make smoke-local
```

If your local Node version can't run Next.js, skip the UI step:

```bash
make smoke-local SMOKE_ARGS=--skip-ui
```

Smoke test uses a separate Postgres database (default `saas_core_template_smoke`) and recreates it each run. Override with `SMOKE_DB_NAME=<name>`.

Start backend in one terminal:

```bash
make dev-api
```

Start worker in another terminal (jobs + email):

```bash
make dev-worker
```

Start frontend in another terminal:

```bash
Expand Down
48 changes: 48 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,54 @@ PORT=8080
DATABASE_URL=postgres://postgres:postgres@localhost:5432/saas_core_template?sslmode=disable
REDIS_URL=redis://localhost:6379
APP_BASE_URL=http://localhost:3000

# Observability (OpenTelemetry)
# - Local default: console spans in backend logs.
# - To use the local collector (docker compose service `otel-collector`): set OTEL_TRACES_EXPORTER=otlp.
OTEL_SERVICE_NAME=saas-core-template-backend
OTEL_TRACES_EXPORTER=console
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Example for Grafana Cloud OTLP auth:
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64(instance_id:api_token)>
OTEL_EXPORTER_OTLP_HEADERS=

# Error reporting (Sentry)
# - Local default: logs errors to console.
# - To enable Sentry: set ERROR_REPORTING_PROVIDER=sentry and SENTRY_DSN.
ERROR_REPORTING_PROVIDER=console
SENTRY_DSN=
SENTRY_ENVIRONMENT=development

# Analytics (PostHog)
# - Local default: logs events to console.
# - To enable PostHog: set ANALYTICS_PROVIDER=posthog and POSTHOG_PROJECT_KEY.
ANALYTICS_PROVIDER=console
POSTHOG_PROJECT_KEY=
POSTHOG_HOST=https://app.posthog.com

# Email (Resend)
# - Local default: logs emails to console.
# - To enable Resend: set EMAIL_PROVIDER=resend, RESEND_API_KEY, and EMAIL_FROM.
EMAIL_PROVIDER=console
EMAIL_FROM=
RESEND_API_KEY=

# Background jobs
JOBS_ENABLED=true
JOBS_WORKER_ID=local
JOBS_POLL_INTERVAL=1s

# File uploads
# - Local default: store files on disk under FILE_STORAGE_DISK_PATH.
# - For S3/R2: set FILE_STORAGE_PROVIDER=s3 and configure S3_* variables.
FILE_STORAGE_PROVIDER=disk
FILE_STORAGE_DISK_PATH=./.data/uploads
S3_BUCKET=
S3_REGION=auto
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
CLERK_SECRET_KEY=
CLERK_API_URL=https://api.clerk.com
STRIPE_SECRET_KEY=
Expand Down
100 changes: 98 additions & 2 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ import (
"syscall"
"time"

"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"saas-core-template/backend/internal/analytics"
"saas-core-template/backend/internal/api"
"saas-core-template/backend/internal/audit"
"saas-core-template/backend/internal/auth"
"saas-core-template/backend/internal/billing"
"saas-core-template/backend/internal/cache"
"saas-core-template/backend/internal/config"
"saas-core-template/backend/internal/db"
"saas-core-template/backend/internal/errorreporting"
"saas-core-template/backend/internal/files"
"saas-core-template/backend/internal/jobs"
"saas-core-template/backend/internal/orgs"
"saas-core-template/backend/internal/telemetry"
)

const appName = "saas-core-template-api"
Expand All @@ -30,6 +38,52 @@ func main() {

ctx := context.Background()

analyticsProvider, err := analytics.ProviderFromEnv(cfg.AnalyticsProvider)
if err != nil {
slog.Error("failed to parse analytics provider", "error", err)
os.Exit(1)
}

var analyticsClient analytics.Client
switch analyticsProvider {
case "none":
analyticsClient = analytics.NewNoop()
case "posthog":
analyticsClient = analytics.NewPostHog(cfg.PostHogProjectKey, cfg.PostHogHost)
default:
analyticsClient = analytics.NewConsole()
}

shutdownTelemetry, err := telemetry.Init(ctx, telemetry.Config{
ServiceName: cfg.ServiceName,
Environment: cfg.Env,
Version: cfg.Version,
TracesExporter: cfg.OtelTracesExporter,
OTLPEndpoint: cfg.OtelOTLPEndpoint,
OTLPHeaders: telemetry.ParseOTLPHeaders(cfg.OtelOTLPHeadersRaw),
})
if err != nil {
slog.Error("failed to initialize telemetry", "error", err)
os.Exit(1)
}
defer func() {
_ = shutdownTelemetry(context.Background())
}()

reporter, err := errorreporting.New(ctx, errorreporting.Config{
Provider: cfg.ErrorReportingProvider,
DSN: cfg.SentryDSN,
Environment: cfg.SentryEnvironment,
Release: cfg.Version,
})
if err != nil {
slog.Error("failed to initialize error reporting", "error", err)
os.Exit(1)
}
defer func() {
_ = reporter.Shutdown(context.Background())
}()

pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to postgres", "error", err)
Expand All @@ -46,10 +100,43 @@ func main() {
_ = redisClient.Close()
}()

auditRecorder := audit.NewDBRecorder(pool)
jobStore := jobs.NewStore(pool)
orgService := orgs.NewService(pool, orgs.WithJobs(jobStore), orgs.WithAudit(auditRecorder))

var s3Provider *files.S3Provider
if cfg.FileStorageProvider == "s3" {
p, err := files.NewS3Provider(ctx, files.S3Config{
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
Endpoint: cfg.S3Endpoint,
AccessKeyID: cfg.S3AccessKeyID,
SecretAccessKey: cfg.S3SecretAccessKey,
ForcePathStyle: cfg.S3ForcePathStyle,
})
if err != nil {
slog.Error("failed to initialize s3 provider", "error", err)
os.Exit(1)
}
s3Provider = p
}

var filesService *files.Service
switch cfg.FileStorageProvider {
case "none", "noop", "off", "disabled":
filesService = nil
default:
filesService = files.NewService(pool, files.Config{
Provider: cfg.FileStorageProvider,
DiskPath: cfg.FileStorageDiskPath,
S3: s3Provider,
})
}

var authService *auth.Service
if cfg.ClerkSecretKey != "" {
authProvider := auth.NewClerkProvider(cfg.ClerkSecretKey, cfg.ClerkAPIURL)
authService = auth.NewService(authProvider, pool)
authService = auth.NewService(authProvider, pool, auth.WithJobs(jobStore), auth.WithAudit(auditRecorder))
}

var billingService *billing.Service
Expand All @@ -73,10 +160,19 @@ func main() {
api.WithAuthService(authService),
api.WithBillingService(billingService),
api.WithAppBaseURL(cfg.AppBaseURL),
api.WithAnalytics(analyticsClient),
api.WithAudit(auditRecorder),
api.WithFiles(filesService),
api.WithOrgs(orgService),
)

baseHandler := apiServer.Handler()
baseHandler = errorreporting.NewMiddleware(reporter).Wrap(baseHandler)
baseHandler = otelhttp.NewHandler(baseHandler, "http")

httpServer := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.Port),
Handler: apiServer.Handler(),
Handler: baseHandler,
ReadHeaderTimeout: 5 * time.Second,
}

Expand Down
Loading
Loading