initiall commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:03:31 +02:00
commit 7ddfb1f52b
276 changed files with 37629 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git build-base
# Copy root configs for workspace context
COPY go.mod go.sum ./
COPY apps/backend ./apps/backend
RUN go mod download
WORKDIR /app/apps/backend
RUN go build -o /primora-backend ./cmd/server
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
COPY --from=builder /app/apps/backend/db ./db
COPY --from=builder /app/apps/backend/openapi ./openapi
EXPOSE 8080
CMD ["primora-backend"]
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"context"
"log"
"github.com/tdvorak/primora/apps/backend/internal/app"
)
func main() {
application, err := app.Bootstrap(context.Background())
if err != nil {
log.Fatal(err)
}
defer application.Close()
if err := application.Run(); err != nil {
log.Fatal(err)
}
}
@@ -0,0 +1,148 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS core;
CREATE TYPE core.org_role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE core.project_role AS ENUM ('admin', 'developer', 'viewer');
CREATE TYPE core.bucket_visibility AS ENUM ('private', 'public');
CREATE TABLE core.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auth_subject TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role core.org_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, user_id)
);
CREATE TABLE core.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE TABLE core.project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role core.project_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);
CREATE TABLE core.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prefix TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.buckets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
visibility core.bucket_visibility NOT NULL DEFAULT 'private',
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, slug)
);
CREATE TABLE core.bucket_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id UUID NOT NULL REFERENCES core.buckets(id) ON DELETE CASCADE,
object_key TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
checksum_sha256 TEXT NOT NULL,
storage_path TEXT NOT NULL UNIQUE,
uploaded_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (bucket_id, object_key)
);
CREATE TABLE core.project_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
email TEXT NOT NULL,
org_role core.org_role NOT NULL DEFAULT 'member',
project_role core.project_role,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
invited_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES core.organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
actor_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
actor_api_key_id UUID REFERENCES core.api_keys(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
request_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_auth_subject ON core.users(auth_subject);
CREATE INDEX idx_org_members_user_id ON core.organization_members(user_id);
CREATE INDEX idx_projects_org_id ON core.projects(organization_id);
CREATE INDEX idx_project_members_user_id ON core.project_members(user_id);
CREATE INDEX idx_api_keys_project_id ON core.api_keys(project_id);
CREATE INDEX idx_api_keys_prefix ON core.api_keys(prefix);
CREATE INDEX idx_buckets_project_id ON core.buckets(project_id);
CREATE INDEX idx_bucket_objects_bucket_id ON core.bucket_objects(bucket_id);
CREATE INDEX idx_bucket_objects_bucket_key ON core.bucket_objects(bucket_id, object_key);
CREATE INDEX idx_project_invitations_email ON core.project_invitations(email);
CREATE INDEX idx_audit_logs_project_id ON core.audit_logs(project_id, created_at DESC);
CREATE INDEX idx_audit_logs_org_id ON core.audit_logs(organization_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.audit_logs;
DROP TABLE IF EXISTS core.project_invitations;
DROP TABLE IF EXISTS core.bucket_objects;
DROP TABLE IF EXISTS core.buckets;
DROP TABLE IF EXISTS core.api_keys;
DROP TABLE IF EXISTS core.project_members;
DROP TABLE IF EXISTS core.projects;
DROP TABLE IF EXISTS core.organization_members;
DROP TABLE IF EXISTS core.organizations;
DROP TABLE IF EXISTS core.users;
DROP TYPE IF EXISTS core.bucket_visibility;
DROP TYPE IF EXISTS core.project_role;
DROP TYPE IF EXISTS core.org_role;
DROP SCHEMA IF EXISTS core;
@@ -0,0 +1,30 @@
-- +goose Up
CREATE TABLE core.collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
schema JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, slug)
);
CREATE TABLE core.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id UUID NOT NULL REFERENCES core.collections(id) ON DELETE CASCADE,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_collections_project_id ON core.collections(project_id);
CREATE INDEX idx_documents_collection_id ON core.documents(collection_id);
CREATE INDEX idx_documents_data ON core.documents USING gin (data);
-- +goose Down
DROP TABLE IF EXISTS core.documents;
DROP TABLE IF EXISTS core.collections;
+40
View File
@@ -0,0 +1,40 @@
-- name: CreateAPIKey :one
INSERT INTO core.api_keys (
project_id,
name,
prefix,
secret_hash,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: ListAPIKeysForProject :many
SELECT * FROM core.api_keys
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: GetAPIKeyByIDForProject :one
SELECT * FROM core.api_keys
WHERE project_id = $1
AND id = $2;
-- name: GetAPIKeyByPrefix :one
SELECT
ak.*,
p.organization_id
FROM core.api_keys ak
JOIN core.projects p ON p.id = ak.project_id
WHERE ak.prefix = $1;
-- name: RevokeAPIKey :one
UPDATE core.api_keys
SET revoked_at = NOW()
WHERE project_id = $1
AND id = $2
AND revoked_at IS NULL
RETURNING *;
-- name: TouchAPIKey :exec
UPDATE core.api_keys
SET last_used_at = NOW()
WHERE id = $1;
+59
View File
@@ -0,0 +1,59 @@
-- name: CreateAuditLog :one
INSERT INTO core.audit_logs (
organization_id,
project_id,
actor_user_id,
actor_api_key_id,
action,
resource_type,
resource_id,
metadata,
request_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING *;
-- name: ListAuditLogsForProject :many
SELECT * FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
ORDER BY created_at DESC
LIMIT $4
OFFSET $5;
-- name: CountAuditLogsForProject :one
SELECT COUNT(*)::BIGINT
FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
);
+29
View File
@@ -0,0 +1,29 @@
-- name: BootstrapOrganization :one
WITH new_org AS (
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING *
),
new_org_member AS (
INSERT INTO core.organization_members (organization_id, user_id, role)
SELECT id, $3, 'owner'::core.org_role FROM new_org
),
new_project AS (
INSERT INTO core.projects (organization_id, slug, name, description)
SELECT id, $4, $5, $6 FROM new_org
RETURNING *
),
new_project_member AS (
INSERT INTO core.project_members (project_id, user_id, role)
SELECT id, $3, 'admin'::core.project_role FROM new_project
)
SELECT
new_org.id AS organization_id,
new_org.slug AS organization_slug,
new_org.name AS organization_name,
new_project.id AS project_id,
new_project.slug AS project_slug,
new_project.name AS project_name
FROM new_org
JOIN new_project ON TRUE;
+40
View File
@@ -0,0 +1,40 @@
-- name: CreateBucket :one
INSERT INTO core.buckets (
project_id,
slug,
name,
visibility,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: ListBucketsForProject :many
SELECT * FROM core.buckets
WHERE project_id = $1
AND (
btrim($2) = ''
OR slug ILIKE '%' || btrim($2) || '%'
OR name ILIKE '%' || btrim($2) || '%'
)
ORDER BY created_at ASC;
-- name: GetBucketByID :one
SELECT
b.*,
p.organization_id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE b.id = $1;
-- name: UpdateBucketByID :one
UPDATE core.buckets
SET slug = $2,
name = $3,
visibility = $4
WHERE id = $1
RETURNING *;
-- name: DeleteBucketByID :one
DELETE FROM core.buckets
WHERE id = $1
RETURNING *;
+66
View File
@@ -0,0 +1,66 @@
-- name: ListCollections :many
SELECT * FROM core.collections
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: GetCollectionBySlug :one
SELECT * FROM core.collections
WHERE project_id = $1 AND slug = $2;
-- name: GetCollectionByID :one
SELECT * FROM core.collections
WHERE id = $1;
-- name: CreateCollection :one
INSERT INTO core.collections (
project_id, slug, name, description, schema, created_by_user_id
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING *;
-- name: UpdateCollection :one
UPDATE core.collections
SET
name = $3,
description = $4,
schema = $5,
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteCollection :exec
DELETE FROM core.collections
WHERE id = $1 AND project_id = $2;
-- name: ListDocuments :many
SELECT * FROM core.documents
WHERE collection_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CountDocuments :one
SELECT COUNT(*) FROM core.documents
WHERE collection_id = $1;
-- name: GetDocumentByID :one
SELECT * FROM core.documents
WHERE id = $1 AND collection_id = $2;
-- name: CreateDocument :one
INSERT INTO core.documents (
collection_id, data, created_by_user_id
) VALUES (
$1, $2, $3
) RETURNING *;
-- name: UpdateDocument :one
UPDATE core.documents
SET
data = $3,
updated_at = NOW()
WHERE id = $1 AND collection_id = $2
RETURNING *;
-- name: DeleteDocument :exec
DELETE FROM core.documents
WHERE id = $1 AND collection_id = $2;
+45
View File
@@ -0,0 +1,45 @@
-- name: CreateBucketObject :one
INSERT INTO core.bucket_objects (
bucket_id,
object_key,
content_type,
size_bytes,
checksum_sha256,
storage_path,
uploaded_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListBucketObjects :many
SELECT * FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
ORDER BY created_at DESC
LIMIT $3
OFFSET $4;
-- name: CountBucketObjects :one
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%');
-- name: GetBucketObjectByKey :one
SELECT * FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2;
-- name: MoveBucketObject :one
UPDATE core.bucket_objects
SET bucket_id = $3,
object_key = $4,
storage_path = $5
WHERE bucket_id = $1
AND object_key = $2
RETURNING *;
-- name: DeleteBucketObjectByKey :one
DELETE FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
RETURNING *;
+149
View File
@@ -0,0 +1,149 @@
-- name: CreateOrganization :one
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING *;
-- name: UpdateOrganizationByID :one
UPDATE core.organizations
SET slug = $2,
name = $3
WHERE id = $1
RETURNING *;
-- name: ListOrganizationsForUser :many
SELECT
o.*,
om.role AS membership_role
FROM core.organizations o
JOIN core.organization_members om ON om.organization_id = o.id
WHERE om.user_id = $1
ORDER BY o.created_at ASC;
-- name: GetOrganizationMembership :one
SELECT
om.*,
o.name AS organization_name,
o.slug AS organization_slug
FROM core.organization_members om
JOIN core.organizations o ON o.id = om.organization_id
WHERE om.organization_id = $1
AND om.user_id = $2;
-- name: ListOrganizationMembers :many
SELECT
om.organization_id,
om.user_id,
om.role,
om.created_at,
u.email,
u.name,
u.email_verified
FROM core.organization_members om
JOIN core.users u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY om.created_at ASC;
-- name: UpdateOrganizationMemberRole :one
UPDATE core.organization_members
SET role = $3
WHERE organization_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveOrganizationMember :one
DELETE FROM core.organization_members
WHERE organization_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveProjectMembershipsForOrganizationUser :exec
DELETE FROM core.project_members pm
USING core.projects p
WHERE pm.project_id = p.id
AND p.organization_id = $1
AND pm.user_id = $2;
-- name: CountOrganizationOwners :one
SELECT COUNT(*)::BIGINT FROM core.organization_members
WHERE organization_id = $1
AND role = 'owner';
-- name: CreateInvitation :one
INSERT INTO core.project_invitations (
organization_id,
project_id,
email,
org_role,
project_role,
token_hash,
expires_at,
invited_by_user_id
) VALUES (
$1,
$2,
LOWER($3),
$4,
$5,
$6,
$7,
$8
)
RETURNING *;
-- name: GetInvitationByTokenHash :one
SELECT * FROM core.project_invitations
WHERE token_hash = $1;
-- name: GetInvitationByIDForOrganization :one
SELECT * FROM core.project_invitations
WHERE organization_id = $1
AND id = $2;
-- name: ListInvitationsForOrganization :many
SELECT
i.id,
i.organization_id,
i.project_id,
p.name AS project_name,
i.email,
i.org_role,
i.project_role,
i.expires_at,
i.accepted_at,
i.invited_by_user_id,
i.created_at
FROM core.project_invitations i
LEFT JOIN core.projects p ON p.id = i.project_id
WHERE i.organization_id = $1
ORDER BY i.created_at DESC;
-- name: DeletePendingInvitationByIDForOrganization :one
DELETE FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
AND accepted_at IS NULL
RETURNING *;
-- name: MarkInvitationAccepted :one
UPDATE core.project_invitations
SET accepted_at = NOW()
WHERE id = $1
RETURNING *;
-- name: AddOrganizationMember :one
INSERT INTO core.organization_members (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING *;
-- name: ListBucketsForOrganization :many
SELECT b.id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE p.organization_id = $1;
-- name: DeleteOrganizationByID :one
DELETE FROM core.organizations
WHERE id = $1
RETURNING *;
+146
View File
@@ -0,0 +1,146 @@
-- name: ListProjectsForOrganization :many
SELECT
p.*,
pm.role AS membership_role
FROM core.projects p
LEFT JOIN core.project_members pm
ON pm.project_id = p.id
AND pm.user_id = $2
WHERE p.organization_id = $1
AND (
btrim($3) = ''
OR p.slug ILIKE '%' || btrim($3) || '%'
OR p.name ILIKE '%' || btrim($3) || '%'
OR COALESCE(p.description, '') ILIKE '%' || btrim($3) || '%'
)
ORDER BY p.created_at ASC;
-- name: CreateProject :one
INSERT INTO core.projects (
organization_id,
slug,
name,
description
) VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: UpdateProjectByID :one
UPDATE core.projects
SET slug = $2,
name = $3,
description = $4
WHERE id = $1
RETURNING *;
-- name: AddProjectMember :one
INSERT INTO core.project_members (project_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING *;
-- name: GetProjectMembership :one
SELECT
pm.*,
p.organization_id
FROM core.project_members pm
JOIN core.projects p ON p.id = pm.project_id
WHERE pm.project_id = $1
AND pm.user_id = $2;
-- name: ListProjectMembers :many
SELECT
pm.project_id,
pm.user_id,
pm.role,
pm.created_at,
u.email,
u.name,
u.email_verified
FROM core.project_members pm
JOIN core.users u ON u.id = pm.user_id
WHERE pm.project_id = $1
ORDER BY pm.created_at ASC;
-- name: UpdateProjectMemberRole :one
UPDATE core.project_members
SET role = $3
WHERE project_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveProjectMember :one
DELETE FROM core.project_members
WHERE project_id = $1
AND user_id = $2
RETURNING *;
-- name: CountProjectAdmins :one
SELECT COUNT(*)::BIGINT FROM core.project_members
WHERE project_id = $1
AND role = 'admin';
-- name: GetProjectByID :one
SELECT * FROM core.projects
WHERE id = $1;
-- name: GetProjectOverview :one
SELECT
p.id AS project_id,
p.organization_id,
p.slug AS project_slug,
p.name AS project_name,
(
SELECT COUNT(*)::BIGINT
FROM core.project_members pm
WHERE pm.project_id = p.id
) AS member_count,
(
SELECT COUNT(*)::BIGINT
FROM core.api_keys ak
WHERE ak.project_id = p.id
AND ak.revoked_at IS NULL
) AS active_api_key_count,
(
SELECT COUNT(*)::BIGINT
FROM core.buckets b
WHERE b.project_id = p.id
) AS bucket_count,
(
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_count,
(
SELECT COALESCE(SUM(bo.size_bytes), 0)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_bytes_total,
(
SELECT COUNT(*)::BIGINT
FROM core.project_invitations pi
WHERE pi.organization_id = p.organization_id
AND (pi.project_id IS NULL OR pi.project_id = p.id)
AND pi.accepted_at IS NULL
AND pi.expires_at > NOW()
) AS pending_invitation_count,
(
SELECT COUNT(*)::BIGINT
FROM core.audit_logs al
WHERE al.project_id = p.id
AND al.created_at >= NOW() - INTERVAL '24 hours'
) AS audit_events_24h,
(
SELECT MAX(al.created_at)::TIMESTAMPTZ
FROM core.audit_logs al
WHERE al.project_id = p.id
) AS last_audit_at
FROM core.projects p
WHERE p.id = $1;
-- name: DeleteProjectByID :one
DELETE FROM core.projects
WHERE id = $1
RETURNING *;
+35
View File
@@ -0,0 +1,35 @@
-- name: UpsertUser :one
INSERT INTO core.users (
auth_subject,
email,
name,
email_verified,
updated_at,
last_seen_at
) VALUES (
$1,
$2,
$3,
$4,
NOW(),
NOW()
)
ON CONFLICT (auth_subject) DO UPDATE
SET
email = EXCLUDED.email,
name = EXCLUDED.name,
email_verified = EXCLUDED.email_verified,
updated_at = NOW(),
last_seen_at = NOW()
RETURNING *;
-- name: GetUserByAuthSubject :one
SELECT * FROM core.users
WHERE auth_subject = $1;
-- name: GetUserByID :one
SELECT * FROM core.users
WHERE id = $1;
-- name: CountOrganizations :one
SELECT COUNT(*)::BIGINT FROM core.organizations;
+66
View File
@@ -0,0 +1,66 @@
module github.com/tdvorak/primora/apps/backend
go 1.26.0
require (
github.com/MicahParks/keyfunc/v3 v3.7.0
github.com/gin-gonic/gin v1.11.0
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0
github.com/redis/go-redis/v9 v9.16.0
github.com/resend/resend-go/v2 v2.15.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+166
View File
@@ -0,0 +1,166 @@
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+168
View File
@@ -0,0 +1,168 @@
package app
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/primora/apps/backend/internal/auth"
"github.com/tdvorak/primora/apps/backend/internal/config"
"github.com/tdvorak/primora/apps/backend/internal/database"
"github.com/tdvorak/primora/apps/backend/internal/handlers"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/services"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
type App struct {
Config config.Config
Router *gin.Engine
Logger *slog.Logger
DB *pgxpool.Pool
Redis *redis.Client
}
func Bootstrap(ctx context.Context) (*App, error) {
_ = godotenv.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
return nil, err
}
dbPool, err := database.Connect(ctx, cfg.DatabaseURL)
if err != nil {
return nil, err
}
if err := database.RunMigrationsFromPool(dbPool, database.ResolveMigrationsDir(), logger); err != nil {
return nil, err
}
var verifier *auth.Verifier
for attempt := 1; attempt <= 20; attempt++ {
verifier, err = auth.NewVerifier(ctx, cfg.AuthInternalBaseURL+"/auth/jwks", cfg.JWTIssuer, cfg.JWTAudience)
if err == nil {
break
}
logger.Warn("auth jwks unavailable, retrying", "attempt", attempt, "error", err)
time.Sleep(2 * time.Second)
}
if verifier == nil {
return nil, err
}
var redisClient *redis.Client
if options, err := redis.ParseURL(cfg.DragonflyURL); err != nil {
logger.Warn("dragonfly configuration invalid, continuing in degraded mode", "error", err)
} else {
redisClient = redis.NewClient(options)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Warn("dragonfly unavailable, continuing in degraded mode", "error", err)
redisClient = nil
}
}
store, err := storage.NewLocalStore(cfg.StorageRoot)
if err != nil {
return nil, err
}
repo := repositories.NewCoreRepository(dbPool)
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"))
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.RequestID())
router.Use(middleware.Logger(logger))
metrics := observability.NewMetrics()
router.Use(middleware.Metrics(metrics))
router.Use(middleware.Compression())
// CORS configuration - update AllowedOrigins for production
corsOrigins := []string{cfg.PublicURL}
if cfg.Env == "development" {
corsOrigins = append(corsOrigins, "http://localhost", "http://localhost:3000")
}
router.Use(middleware.CORS(middleware.CORSConfig{
AllowedOrigins: corsOrigins,
}))
router.Use(middleware.AuthMiddleware{
Queries: repo,
Logger: logger,
Redis: redisClient,
Verifier: verifier,
RateLimits: middleware.RateLimitConfig{
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
UserPerMinute: cfg.UserRateLimitPerMin,
},
}.ResolveActor())
handler := &handlers.HTTPHandler{
Platform: platform,
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
status := map[string]any{
"status": "ok",
"checks": map[string]any{
"database": "ok",
"storage": "ok",
"dragonfly": "ok",
},
"metrics": metrics.GetStats(),
}
if err := dbPool.Ping(c.Request.Context()); err != nil {
status["status"] = "degraded"
status["checks"].(map[string]any)["database"] = err.Error()
}
if redisClient != nil {
if err := redisClient.Ping(c.Request.Context()).Err(); err != nil {
status["status"] = "degraded"
status["checks"].(map[string]any)["dragonfly"] = err.Error()
}
} else {
status["status"] = "degraded"
status["checks"].(map[string]any)["dragonfly"] = "disabled"
}
return status
},
}
handler.Register(router)
return &App{
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
}, nil
}
func (a *App) Run() error {
address := ":" + a.Config.ServerPort
a.Logger.Info("primora backend starting", "address", address)
return a.Router.Run(address)
}
func (a *App) Close() error {
if a.Redis != nil {
if err := a.Redis.Close(); err != nil {
return fmt.Errorf("close redis: %w", err)
}
}
a.DB.Close()
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
SessionID string `json:"sid"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name,omitempty"`
jwt.RegisteredClaims
}
func ParseToken(rawToken, secret, issuer, audience string) (*Claims, error) {
token, err := jwt.ParseWithClaims(rawToken, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method %s", token.Method.Alg())
}
return []byte(secret), nil
}, jwt.WithIssuer(issuer), jwt.WithAudience(audience), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) {
return nil, fmt.Errorf("token expired")
}
return claims, nil
}
type Verifier struct {
keyfunc jwt.Keyfunc
issuer string
audience string
}
func NewVerifier(ctx context.Context, jwksURL, issuer, audience string) (*Verifier, error) {
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, fmt.Errorf("create jwks verifier: %w", err)
}
return &Verifier{
keyfunc: jwks.Keyfunc,
issuer: issuer,
audience: audience,
}, nil
}
func (v *Verifier) ParseToken(rawToken string) (*Claims, error) {
token, err := jwt.ParseWithClaims(rawToken, &Claims{}, v.keyfunc, jwt.WithIssuer(v.issuer), jwt.WithAudience(v.audience))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) {
return nil, fmt.Errorf("token expired")
}
return claims, nil
}
+131
View File
@@ -0,0 +1,131 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Env string
ServerPort string
DatabaseURL string
DragonflyURL string
StorageRoot string
AuthInternalBaseURL string
PublicURL string
UserRateLimitPerMin int
APIKeyRateLimitPerMin int
JWTIssuer string
JWTAudience string
JWTSecret string
JWTTTLSeconds int
MailFrom string
ResendAPIKey string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPSecure bool
}
func Load() (Config, error) {
cfg := Config{
Env: getenv("NODE_ENV", "development"),
ServerPort: getenv("BACKEND_PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
DragonflyURL: getenv("DRAGONFLY_URL", "redis://localhost:6379/0"),
StorageRoot: getenv("BACKEND_STORAGE_ROOT", "./tmp/storage"),
AuthInternalBaseURL: getenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001"),
PublicURL: getenv("VITE_APP_URL", "http://localhost"),
UserRateLimitPerMin: 240,
APIKeyRateLimitPerMin: 600,
JWTIssuer: getenv("JWT_ISSUER", "primora-auth"),
JWTAudience: getenv("JWT_AUDIENCE", "primora-api"),
JWTSecret: os.Getenv("JWT_SECRET"),
MailFrom: getenv("MAIL_FROM", "Primora <no-reply@primora.local>"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
SMTPHost: getenv("SMTP_HOST", "localhost"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err)
}
cfg.SMTPPort = smtpPort
jwtTTLSeconds, err := strconv.Atoi(getenv("JWT_TTL_SECONDS", "900"))
if err != nil {
return Config{}, fmt.Errorf("parse JWT_TTL_SECONDS: %w", err)
}
cfg.JWTTTLSeconds = jwtTTLSeconds
userRateLimitPerMin, err := parseNonNegativeIntEnv("USER_RATE_LIMIT_PER_MINUTE", cfg.UserRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.UserRateLimitPerMin = userRateLimitPerMin
apiKeyRateLimitPerMin, err := parseNonNegativeIntEnv("API_KEY_RATE_LIMIT_PER_MINUTE", cfg.APIKeyRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.APIKeyRateLimitPerMin = apiKeyRateLimitPerMin
smtpSecure, err := strconv.ParseBool(getenv("SMTP_SECURE", "false"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_SECURE: %w", err)
}
cfg.SMTPSecure = smtpSecure
var missing []string
if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL")
}
if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET")
}
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
}
if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
}
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
missing = append(missing, "RESEND_API_KEY or SMTP_HOST")
}
if len(missing) > 0 {
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
}
return cfg, nil
}
func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func parseNonNegativeIntEnv(key string, fallback int) (int, error) {
raw := os.Getenv(key)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse %s: %w", key, err)
}
if value < 0 {
return 0, fmt.Errorf("%s must be >= 0", key)
}
return value, nil
}
@@ -0,0 +1,58 @@
package config
import (
"strings"
"testing"
)
func setRequiredEnv(t *testing.T) {
t.Helper()
t.Setenv("DATABASE_URL", "postgres://primora:primora@localhost:5432/primora?sslmode=disable")
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("BACKEND_STORAGE_ROOT", "./tmp/storage")
t.Setenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001")
t.Setenv("SMTP_HOST", "mailpit")
}
func TestLoadRateLimitDefaults(t *testing.T) {
setRequiredEnv(t)
t.Setenv("USER_RATE_LIMIT_PER_MINUTE", "")
t.Setenv("API_KEY_RATE_LIMIT_PER_MINUTE", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.UserRateLimitPerMin != 240 {
t.Fatalf("unexpected USER_RATE_LIMIT_PER_MINUTE default: %d", cfg.UserRateLimitPerMin)
}
if cfg.APIKeyRateLimitPerMin != 600 {
t.Fatalf("unexpected API_KEY_RATE_LIMIT_PER_MINUTE default: %d", cfg.APIKeyRateLimitPerMin)
}
}
func TestLoadRateLimitRejectsInvalidNumber(t *testing.T) {
setRequiredEnv(t)
t.Setenv("USER_RATE_LIMIT_PER_MINUTE", "not-a-number")
_, err := Load()
if err == nil {
t.Fatalf("expected parse error")
}
if !strings.Contains(err.Error(), "parse USER_RATE_LIMIT_PER_MINUTE") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadRateLimitRejectsNegative(t *testing.T) {
setRequiredEnv(t)
t.Setenv("API_KEY_RATE_LIMIT_PER_MINUTE", "-1")
_, err := Load()
if err == nil {
t.Fatalf("expected validation error")
}
if !strings.Contains(err.Error(), "API_KEY_RATE_LIMIT_PER_MINUTE must be >= 0") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -0,0 +1,84 @@
package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
var lastErr error
for attempt := 1; attempt <= 20; attempt++ {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
config.MaxConnLifetime = 30 * time.Minute
config.MaxConns = 20
config.MinConns = 2
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
lastErr = fmt.Errorf("create pool: %w", err)
} else if err := pool.Ping(ctx); err != nil {
pool.Close()
lastErr = fmt.Errorf("ping database: %w", err)
} else {
return pool, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
}
}
return nil, lastErr
}
func RunMigrations(databaseURL, migrationsDir string, logger *slog.Logger) error {
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("set goose dialect: %w", err)
}
sqlDB, err := sql.Open("pgx", databaseURL)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return fmt.Errorf("goose up: %w", err)
}
logger.Info("database migrations complete", "dir", migrationsDir)
return nil
}
func RunMigrationsFromPool(pool *pgxpool.Pool, migrationsDir string, logger *slog.Logger) error {
sqlDB := stdlib.OpenDBFromPool(pool)
defer sqlDB.Close()
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("set goose dialect: %w", err)
}
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return fmt.Errorf("goose up: %w", err)
}
logger.Info("database migrations complete", "dir", migrationsDir)
return nil
}
func ResolveMigrationsDir() string {
if dir := os.Getenv("BACKEND_MIGRATIONS_DIR"); dir != "" {
return dir
}
return filepath.Join("db", "migrations")
}
@@ -0,0 +1,201 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: api_keys.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAPIKey = `-- name: CreateAPIKey :one
INSERT INTO core.api_keys (
project_id,
name,
prefix,
secret_hash,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
`
type CreateAPIKeyParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, createAPIKey,
arg.ProjectID,
arg.Name,
arg.Prefix,
arg.SecretHash,
arg.CreatedByUserID,
)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getAPIKeyByIDForProject = `-- name: GetAPIKeyByIDForProject :one
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
WHERE project_id = $1
AND id = $2
`
type GetAPIKeyByIDForProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, getAPIKeyByIDForProject, arg.ProjectID, arg.ID)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getAPIKeyByPrefix = `-- name: GetAPIKeyByPrefix :one
SELECT
ak.id, ak.project_id, ak.name, ak.prefix, ak.secret_hash, ak.created_by_user_id, ak.last_used_at, ak.revoked_at, ak.created_at,
p.organization_id
FROM core.api_keys ak
JOIN core.projects p ON p.id = ak.project_id
WHERE ak.prefix = $1
`
type GetAPIKeyByPrefixRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) {
row := q.db.QueryRow(ctx, getAPIKeyByPrefix, prefix)
var i GetAPIKeyByPrefixRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const listAPIKeysForProject = `-- name: ListAPIKeysForProject :many
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) {
rows, err := q.db.Query(ctx, listAPIKeysForProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreApiKey
for rows.Next() {
var i CoreApiKey
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeAPIKey = `-- name: RevokeAPIKey :one
UPDATE core.api_keys
SET revoked_at = NOW()
WHERE project_id = $1
AND id = $2
AND revoked_at IS NULL
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
`
type RevokeAPIKeyParams struct {
ProjectID uuid.UUID `json:"project_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, revokeAPIKey, arg.ProjectID, arg.ID)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const touchAPIKey = `-- name: TouchAPIKey :exec
UPDATE core.api_keys
SET last_used_at = NOW()
WHERE id = $1
`
func (q *Queries) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, touchAPIKey, id)
return err
}
@@ -0,0 +1,175 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: audit.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAuditLogsForProject = `-- name: CountAuditLogsForProject :one
SELECT COUNT(*)::BIGINT
FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
`
type CountAuditLogsForProjectParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Btrim string `json:"btrim"`
Btrim_2 string `json:"btrim_2"`
}
func (q *Queries) CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error) {
row := q.db.QueryRow(ctx, countAuditLogsForProject, arg.ProjectID, arg.Btrim, arg.Btrim_2)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createAuditLog = `-- name: CreateAuditLog :one
INSERT INTO core.audit_logs (
organization_id,
project_id,
actor_user_id,
actor_api_key_id,
action,
resource_type,
resource_id,
metadata,
request_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING id, organization_id, project_id, actor_user_id, actor_api_key_id, action, resource_type, resource_id, metadata, request_id, created_at
`
type CreateAuditLogParams struct {
OrganizationID pgtype.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ActorUserID pgtype.UUID `json:"actor_user_id"`
ActorApiKeyID pgtype.UUID `json:"actor_api_key_id"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Metadata []byte `json:"metadata"`
RequestID string `json:"request_id"`
}
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) {
row := q.db.QueryRow(ctx, createAuditLog,
arg.OrganizationID,
arg.ProjectID,
arg.ActorUserID,
arg.ActorApiKeyID,
arg.Action,
arg.ResourceType,
arg.ResourceID,
arg.Metadata,
arg.RequestID,
)
var i CoreAuditLog
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ActorUserID,
&i.ActorApiKeyID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Metadata,
&i.RequestID,
&i.CreatedAt,
)
return i, err
}
const listAuditLogsForProject = `-- name: ListAuditLogsForProject :many
SELECT id, organization_id, project_id, actor_user_id, actor_api_key_id, action, resource_type, resource_id, metadata, request_id, created_at FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
ORDER BY created_at DESC
LIMIT $4
OFFSET $5
`
type ListAuditLogsForProjectParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Btrim string `json:"btrim"`
Btrim_2 string `json:"btrim_2"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogsForProject,
arg.ProjectID,
arg.Btrim,
arg.Btrim_2,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAuditLog
for rows.Next() {
var i CoreAuditLog
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ActorUserID,
&i.ActorApiKeyID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Metadata,
&i.RequestID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,81 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: bootstrap.sql
package db
import (
"context"
"github.com/google/uuid"
)
const bootstrapOrganization = `-- name: BootstrapOrganization :one
WITH new_org AS (
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING id, slug, name, created_at
),
new_org_member AS (
INSERT INTO core.organization_members (organization_id, user_id, role)
SELECT id, $3, 'owner'::core.org_role FROM new_org
),
new_project AS (
INSERT INTO core.projects (organization_id, slug, name, description)
SELECT id, $4, $5, $6 FROM new_org
RETURNING id, organization_id, slug, name, description, created_at
),
new_project_member AS (
INSERT INTO core.project_members (project_id, user_id, role)
SELECT id, $3, 'admin'::core.project_role FROM new_project
)
SELECT
new_org.id AS organization_id,
new_org.slug AS organization_slug,
new_org.name AS organization_name,
new_project.id AS project_id,
new_project.slug AS project_slug,
new_project.name AS project_name
FROM new_org
JOIN new_project ON TRUE
`
type BootstrapOrganizationParams struct {
Slug string `json:"slug"`
Name string `json:"name"`
UserID uuid.UUID `json:"user_id"`
Slug_2 string `json:"slug_2"`
Name_2 string `json:"name_2"`
Description *string `json:"description"`
}
type BootstrapOrganizationRow struct {
OrganizationID uuid.UUID `json:"organization_id"`
OrganizationSlug string `json:"organization_slug"`
OrganizationName string `json:"organization_name"`
ProjectID uuid.UUID `json:"project_id"`
ProjectSlug string `json:"project_slug"`
ProjectName string `json:"project_name"`
}
func (q *Queries) BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error) {
row := q.db.QueryRow(ctx, bootstrapOrganization,
arg.Slug,
arg.Name,
arg.UserID,
arg.Slug_2,
arg.Name_2,
arg.Description,
)
var i BootstrapOrganizationRow
err := row.Scan(
&i.OrganizationID,
&i.OrganizationSlug,
&i.OrganizationName,
&i.ProjectID,
&i.ProjectSlug,
&i.ProjectName,
)
return i, err
}
@@ -0,0 +1,190 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: buckets.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createBucket = `-- name: CreateBucket :one
INSERT INTO core.buckets (
project_id,
slug,
name,
visibility,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
type CreateBucketParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) {
row := q.db.QueryRow(ctx, createBucket,
arg.ProjectID,
arg.Slug,
arg.Name,
arg.Visibility,
arg.CreatedByUserID,
)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
const deleteBucketByID = `-- name: DeleteBucketByID :one
DELETE FROM core.buckets
WHERE id = $1
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
func (q *Queries) DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) {
row := q.db.QueryRow(ctx, deleteBucketByID, id)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
const getBucketByID = `-- name: GetBucketByID :one
SELECT
b.id, b.project_id, b.slug, b.name, b.visibility, b.created_by_user_id, b.created_at,
p.organization_id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE b.id = $1
`
type GetBucketByIDRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) {
row := q.db.QueryRow(ctx, getBucketByID, id)
var i GetBucketByIDRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const listBucketsForProject = `-- name: ListBucketsForProject :many
SELECT id, project_id, slug, name, visibility, created_by_user_id, created_at FROM core.buckets
WHERE project_id = $1
AND (
btrim($2) = ''
OR slug ILIKE '%' || btrim($2) || '%'
OR name ILIKE '%' || btrim($2) || '%'
)
ORDER BY created_at ASC
`
type ListBucketsForProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
Btrim string `json:"btrim"`
}
func (q *Queries) ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error) {
rows, err := q.db.Query(ctx, listBucketsForProject, arg.ProjectID, arg.Btrim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreBucket
for rows.Next() {
var i CoreBucket
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateBucketByID = `-- name: UpdateBucketByID :one
UPDATE core.buckets
SET slug = $2,
name = $3,
visibility = $4
WHERE id = $1
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
type UpdateBucketByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
}
func (q *Queries) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) {
row := q.db.QueryRow(ctx, updateBucketByID,
arg.ID,
arg.Slug,
arg.Name,
arg.Visibility,
)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,344 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: collections.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const countDocuments = `-- name: CountDocuments :one
SELECT COUNT(*) FROM core.documents
WHERE collection_id = $1
`
func (q *Queries) CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countDocuments, collectionID)
var count int64
err := row.Scan(&count)
return count, err
}
const createCollection = `-- name: CreateCollection :one
INSERT INTO core.collections (
project_id, slug, name, description, schema, created_by_user_id
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at
`
type CreateCollectionParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, createCollection,
arg.ProjectID,
arg.Slug,
arg.Name,
arg.Description,
arg.Schema,
arg.CreatedByUserID,
)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createDocument = `-- name: CreateDocument :one
INSERT INTO core.documents (
collection_id, data, created_by_user_id
) VALUES (
$1, $2, $3
) RETURNING id, collection_id, data, created_by_user_id, created_at, updated_at
`
type CreateDocumentParams struct {
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, createDocument, arg.CollectionID, arg.Data, arg.CreatedByUserID)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteCollection = `-- name: DeleteCollection :exec
DELETE FROM core.collections
WHERE id = $1 AND project_id = $2
`
type DeleteCollectionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error {
_, err := q.db.Exec(ctx, deleteCollection, arg.ID, arg.ProjectID)
return err
}
const deleteDocument = `-- name: DeleteDocument :exec
DELETE FROM core.documents
WHERE id = $1 AND collection_id = $2
`
type DeleteDocumentParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
}
func (q *Queries) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error {
_, err := q.db.Exec(ctx, deleteDocument, arg.ID, arg.CollectionID)
return err
}
const getCollectionByID = `-- name: GetCollectionByID :one
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE id = $1
`
func (q *Queries) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) {
row := q.db.QueryRow(ctx, getCollectionByID, id)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCollectionBySlug = `-- name: GetCollectionBySlug :one
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE project_id = $1 AND slug = $2
`
type GetCollectionBySlugParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
}
func (q *Queries) GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, getCollectionBySlug, arg.ProjectID, arg.Slug)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDocumentByID = `-- name: GetDocumentByID :one
SELECT id, collection_id, data, created_by_user_id, created_at, updated_at FROM core.documents
WHERE id = $1 AND collection_id = $2
`
type GetDocumentByIDParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
}
func (q *Queries) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, getDocumentByID, arg.ID, arg.CollectionID)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listCollections = `-- name: ListCollections :many
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error) {
rows, err := q.db.Query(ctx, listCollections, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreCollection
for rows.Next() {
var i CoreCollection
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDocuments = `-- name: ListDocuments :many
SELECT id, collection_id, data, created_by_user_id, created_at, updated_at FROM core.documents
WHERE collection_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListDocumentsParams struct {
CollectionID uuid.UUID `json:"collection_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) {
rows, err := q.db.Query(ctx, listDocuments, arg.CollectionID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreDocument
for rows.Next() {
var i CoreDocument
if err := rows.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCollection = `-- name: UpdateCollection :one
UPDATE core.collections
SET
name = $3,
description = $4,
schema = $5,
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at
`
type UpdateCollectionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
}
func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, updateCollection,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Description,
arg.Schema,
)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateDocument = `-- name: UpdateDocument :one
UPDATE core.documents
SET
data = $3,
updated_at = NOW()
WHERE id = $1 AND collection_id = $2
RETURNING id, collection_id, data, created_by_user_id, created_at, updated_at
`
type UpdateDocumentParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
}
func (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, updateDocument, arg.ID, arg.CollectionID, arg.Data)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+267
View File
@@ -0,0 +1,267 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql/driver"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type CoreBucketVisibility string
const (
CoreBucketVisibilityPrivate CoreBucketVisibility = "private"
CoreBucketVisibilityPublic CoreBucketVisibility = "public"
)
func (e *CoreBucketVisibility) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreBucketVisibility(s)
case string:
*e = CoreBucketVisibility(s)
default:
return fmt.Errorf("unsupported scan type for CoreBucketVisibility: %T", src)
}
return nil
}
type NullCoreBucketVisibility struct {
CoreBucketVisibility CoreBucketVisibility `json:"core_bucket_visibility"`
Valid bool `json:"valid"` // Valid is true if CoreBucketVisibility is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreBucketVisibility) Scan(value interface{}) error {
if value == nil {
ns.CoreBucketVisibility, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreBucketVisibility.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreBucketVisibility) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreBucketVisibility), nil
}
type CoreOrgRole string
const (
CoreOrgRoleOwner CoreOrgRole = "owner"
CoreOrgRoleAdmin CoreOrgRole = "admin"
CoreOrgRoleMember CoreOrgRole = "member"
)
func (e *CoreOrgRole) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreOrgRole(s)
case string:
*e = CoreOrgRole(s)
default:
return fmt.Errorf("unsupported scan type for CoreOrgRole: %T", src)
}
return nil
}
type NullCoreOrgRole struct {
CoreOrgRole CoreOrgRole `json:"core_org_role"`
Valid bool `json:"valid"` // Valid is true if CoreOrgRole is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreOrgRole) Scan(value interface{}) error {
if value == nil {
ns.CoreOrgRole, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreOrgRole.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreOrgRole) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreOrgRole), nil
}
type CoreProjectRole string
const (
CoreProjectRoleAdmin CoreProjectRole = "admin"
CoreProjectRoleDeveloper CoreProjectRole = "developer"
CoreProjectRoleViewer CoreProjectRole = "viewer"
)
func (e *CoreProjectRole) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreProjectRole(s)
case string:
*e = CoreProjectRole(s)
default:
return fmt.Errorf("unsupported scan type for CoreProjectRole: %T", src)
}
return nil
}
type NullCoreProjectRole struct {
CoreProjectRole CoreProjectRole `json:"core_project_role"`
Valid bool `json:"valid"` // Valid is true if CoreProjectRole is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreProjectRole) Scan(value interface{}) error {
if value == nil {
ns.CoreProjectRole, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreProjectRole.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreProjectRole) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreProjectRole), nil
}
type CoreApiKey struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreAuditLog struct {
ID uuid.UUID `json:"id"`
OrganizationID pgtype.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ActorUserID pgtype.UUID `json:"actor_user_id"`
ActorApiKeyID pgtype.UUID `json:"actor_api_key_id"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Metadata []byte `json:"metadata"`
RequestID string `json:"request_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreBucket struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreBucketObject struct {
ID uuid.UUID `json:"id"`
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSha256 string `json:"checksum_sha256"`
StoragePath string `json:"storage_path"`
UploadedByUserID pgtype.UUID `json:"uploaded_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreCollection struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreDocument struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreOrganization struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreOrganizationMember struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProject struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectInvitation struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
Email string `json:"email"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectMember struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreUser struct {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
}
@@ -0,0 +1,229 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: objects.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const countBucketObjects = `-- name: CountBucketObjects :one
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
`
type CountBucketObjectsParams struct {
BucketID uuid.UUID `json:"bucket_id"`
Btrim string `json:"btrim"`
}
func (q *Queries) CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error) {
row := q.db.QueryRow(ctx, countBucketObjects, arg.BucketID, arg.Btrim)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createBucketObject = `-- name: CreateBucketObject :one
INSERT INTO core.bucket_objects (
bucket_id,
object_key,
content_type,
size_bytes,
checksum_sha256,
storage_path,
uploaded_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type CreateBucketObjectParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSha256 string `json:"checksum_sha256"`
StoragePath string `json:"storage_path"`
UploadedByUserID pgtype.UUID `json:"uploaded_by_user_id"`
}
func (q *Queries) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, createBucketObject,
arg.BucketID,
arg.ObjectKey,
arg.ContentType,
arg.SizeBytes,
arg.ChecksumSha256,
arg.StoragePath,
arg.UploadedByUserID,
)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const deleteBucketObjectByKey = `-- name: DeleteBucketObjectByKey :one
DELETE FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type DeleteBucketObjectByKeyParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
}
func (q *Queries) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, deleteBucketObjectByKey, arg.BucketID, arg.ObjectKey)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const getBucketObjectByKey = `-- name: GetBucketObjectByKey :one
SELECT id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
`
type GetBucketObjectByKeyParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
}
func (q *Queries) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, getBucketObjectByKey, arg.BucketID, arg.ObjectKey)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const listBucketObjects = `-- name: ListBucketObjects :many
SELECT id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
ORDER BY created_at DESC
LIMIT $3
OFFSET $4
`
type ListBucketObjectsParams struct {
BucketID uuid.UUID `json:"bucket_id"`
Btrim string `json:"btrim"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) {
rows, err := q.db.Query(ctx, listBucketObjects,
arg.BucketID,
arg.Btrim,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreBucketObject
for rows.Next() {
var i CoreBucketObject
if err := rows.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveBucketObject = `-- name: MoveBucketObject :one
UPDATE core.bucket_objects
SET bucket_id = $3,
object_key = $4,
storage_path = $5
WHERE bucket_id = $1
AND object_key = $2
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type MoveBucketObjectParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
BucketID_2 uuid.UUID `json:"bucket_id_2"`
ObjectKey_2 string `json:"object_key_2"`
StoragePath string `json:"storage_path"`
}
func (q *Queries) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, moveBucketObject,
arg.BucketID,
arg.ObjectKey,
arg.BucketID_2,
arg.ObjectKey_2,
arg.StoragePath,
)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,594 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: organizations.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const addOrganizationMember = `-- name: AddOrganizationMember :one
INSERT INTO core.organization_members (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, organization_id, user_id, role, created_at
`
type AddOrganizationMemberParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, addOrganizationMember, arg.OrganizationID, arg.UserID, arg.Role)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const countOrganizationOwners = `-- name: CountOrganizationOwners :one
SELECT COUNT(*)::BIGINT FROM core.organization_members
WHERE organization_id = $1
AND role = 'owner'
`
func (q *Queries) CountOrganizationOwners(ctx context.Context, organizationID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countOrganizationOwners, organizationID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createInvitation = `-- name: CreateInvitation :one
INSERT INTO core.project_invitations (
organization_id,
project_id,
email,
org_role,
project_role,
token_hash,
expires_at,
invited_by_user_id
) VALUES (
$1,
$2,
LOWER($3),
$4,
$5,
$6,
$7,
$8
)
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
type CreateInvitationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
Lower string `json:"lower"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
}
func (q *Queries) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, createInvitation,
arg.OrganizationID,
arg.ProjectID,
arg.Lower,
arg.OrgRole,
arg.ProjectRole,
arg.TokenHash,
arg.ExpiresAt,
arg.InvitedByUserID,
)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const createOrganization = `-- name: CreateOrganization :one
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING id, slug, name, created_at
`
type CreateOrganizationParams struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, createOrganization, arg.Slug, arg.Name)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const deleteOrganizationByID = `-- name: DeleteOrganizationByID :one
DELETE FROM core.organizations
WHERE id = $1
RETURNING id, slug, name, created_at
`
func (q *Queries) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, deleteOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const deletePendingInvitationByIDForOrganization = `-- name: DeletePendingInvitationByIDForOrganization :one
DELETE FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
AND accepted_at IS NULL
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
type DeletePendingInvitationByIDForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, deletePendingInvitationByIDForOrganization, arg.OrganizationID, arg.ID)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getInvitationByIDForOrganization = `-- name: GetInvitationByIDForOrganization :one
SELECT id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
`
type GetInvitationByIDForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, getInvitationByIDForOrganization, arg.OrganizationID, arg.ID)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getInvitationByTokenHash = `-- name: GetInvitationByTokenHash :one
SELECT id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at FROM core.project_invitations
WHERE token_hash = $1
`
func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, getInvitationByTokenHash, tokenHash)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at,
o.name AS organization_name,
o.slug AS organization_slug
FROM core.organization_members om
JOIN core.organizations o ON o.id = om.organization_id
WHERE om.organization_id = $1
AND om.user_id = $2
`
type GetOrganizationMembershipParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
type GetOrganizationMembershipRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationName string `json:"organization_name"`
OrganizationSlug string `json:"organization_slug"`
}
func (q *Queries) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) {
row := q.db.QueryRow(ctx, getOrganizationMembership, arg.OrganizationID, arg.UserID)
var i GetOrganizationMembershipRow
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.OrganizationName,
&i.OrganizationSlug,
)
return i, err
}
const listBucketsForOrganization = `-- name: ListBucketsForOrganization :many
SELECT b.id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE p.organization_id = $1
`
func (q *Queries) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) {
rows, err := q.db.Query(ctx, listBucketsForOrganization, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInvitationsForOrganization = `-- name: ListInvitationsForOrganization :many
SELECT
i.id,
i.organization_id,
i.project_id,
p.name AS project_name,
i.email,
i.org_role,
i.project_role,
i.expires_at,
i.accepted_at,
i.invited_by_user_id,
i.created_at
FROM core.project_invitations i
LEFT JOIN core.projects p ON p.id = i.project_id
WHERE i.organization_id = $1
ORDER BY i.created_at DESC
`
type ListInvitationsForOrganizationRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ProjectName *string `json:"project_name"`
Email string `json:"email"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) {
rows, err := q.db.Query(ctx, listInvitationsForOrganization, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListInvitationsForOrganizationRow
for rows.Next() {
var i ListInvitationsForOrganizationRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ProjectName,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrganizationMembers = `-- name: ListOrganizationMembers :many
SELECT
om.organization_id,
om.user_id,
om.role,
om.created_at,
u.email,
u.name,
u.email_verified
FROM core.organization_members om
JOIN core.users u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY om.created_at ASC
`
type ListOrganizationMembersRow struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) {
rows, err := q.db.Query(ctx, listOrganizationMembers, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationMembersRow
for rows.Next() {
var i ListOrganizationMembersRow
if err := rows.Scan(
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.Email,
&i.Name,
&i.EmailVerified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrganizationsForUser = `-- name: ListOrganizationsForUser :many
SELECT
o.id, o.slug, o.name, o.created_at,
om.role AS membership_role
FROM core.organizations o
JOIN core.organization_members om ON om.organization_id = o.id
WHERE om.user_id = $1
ORDER BY o.created_at ASC
`
type ListOrganizationsForUserRow struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
MembershipRole string `json:"membership_role"`
}
func (q *Queries) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) {
rows, err := q.db.Query(ctx, listOrganizationsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationsForUserRow
for rows.Next() {
var i ListOrganizationsForUserRow
if err := rows.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
&i.MembershipRole,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markInvitationAccepted = `-- name: MarkInvitationAccepted :one
UPDATE core.project_invitations
SET accepted_at = NOW()
WHERE id = $1
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
func (q *Queries) MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, markInvitationAccepted, id)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const removeOrganizationMember = `-- name: RemoveOrganizationMember :one
DELETE FROM core.organization_members
WHERE organization_id = $1
AND user_id = $2
RETURNING id, organization_id, user_id, role, created_at
`
type RemoveOrganizationMemberParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, removeOrganizationMember, arg.OrganizationID, arg.UserID)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const removeProjectMembershipsForOrganizationUser = `-- name: RemoveProjectMembershipsForOrganizationUser :exec
DELETE FROM core.project_members pm
USING core.projects p
WHERE pm.project_id = p.id
AND p.organization_id = $1
AND pm.user_id = $2
`
type RemoveProjectMembershipsForOrganizationUserParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error {
_, err := q.db.Exec(ctx, removeProjectMembershipsForOrganizationUser, arg.OrganizationID, arg.UserID)
return err
}
const updateOrganizationByID = `-- name: UpdateOrganizationByID :one
UPDATE core.organizations
SET slug = $2,
name = $3
WHERE id = $1
RETURNING id, slug, name, created_at
`
type UpdateOrganizationByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (q *Queries) UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, updateOrganizationByID, arg.ID, arg.Slug, arg.Name)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const updateOrganizationMemberRole = `-- name: UpdateOrganizationMemberRole :one
UPDATE core.organization_members
SET role = $3
WHERE organization_id = $1
AND user_id = $2
RETURNING id, organization_id, user_id, role, created_at
`
type UpdateOrganizationMemberRoleParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, updateOrganizationMemberRole, arg.OrganizationID, arg.UserID, arg.Role)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,460 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: projects.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const addProjectMember = `-- name: AddProjectMember :one
INSERT INTO core.project_members (project_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, project_id, user_id, role, created_at
`
type AddProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, addProjectMember, arg.ProjectID, arg.UserID, arg.Role)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const countProjectAdmins = `-- name: CountProjectAdmins :one
SELECT COUNT(*)::BIGINT FROM core.project_members
WHERE project_id = $1
AND role = 'admin'
`
func (q *Queries) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countProjectAdmins, projectID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createProject = `-- name: CreateProject :one
INSERT INTO core.projects (
organization_id,
slug,
name,
description
) VALUES ($1, $2, $3, $4)
RETURNING id, organization_id, slug, name, description, created_at
`
type CreateProjectParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) {
row := q.db.QueryRow(ctx, createProject,
arg.OrganizationID,
arg.Slug,
arg.Name,
arg.Description,
)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const deleteProjectByID = `-- name: DeleteProjectByID :one
DELETE FROM core.projects
WHERE id = $1
RETURNING id, organization_id, slug, name, description, created_at
`
func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
row := q.db.QueryRow(ctx, deleteProjectByID, id)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const getProjectByID = `-- name: GetProjectByID :one
SELECT id, organization_id, slug, name, description, created_at FROM core.projects
WHERE id = $1
`
func (q *Queries) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
row := q.db.QueryRow(ctx, getProjectByID, id)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const getProjectMembership = `-- name: GetProjectMembership :one
SELECT
pm.id, pm.project_id, pm.user_id, pm.role, pm.created_at,
p.organization_id
FROM core.project_members pm
JOIN core.projects p ON p.id = pm.project_id
WHERE pm.project_id = $1
AND pm.user_id = $2
`
type GetProjectMembershipParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
type GetProjectMembershipRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) {
row := q.db.QueryRow(ctx, getProjectMembership, arg.ProjectID, arg.UserID)
var i GetProjectMembershipRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const getProjectOverview = `-- name: GetProjectOverview :one
SELECT
p.id AS project_id,
p.organization_id,
p.slug AS project_slug,
p.name AS project_name,
(
SELECT COUNT(*)::BIGINT
FROM core.project_members pm
WHERE pm.project_id = p.id
) AS member_count,
(
SELECT COUNT(*)::BIGINT
FROM core.api_keys ak
WHERE ak.project_id = p.id
AND ak.revoked_at IS NULL
) AS active_api_key_count,
(
SELECT COUNT(*)::BIGINT
FROM core.buckets b
WHERE b.project_id = p.id
) AS bucket_count,
(
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_count,
(
SELECT COALESCE(SUM(bo.size_bytes), 0)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_bytes_total,
(
SELECT COUNT(*)::BIGINT
FROM core.project_invitations pi
WHERE pi.organization_id = p.organization_id
AND (pi.project_id IS NULL OR pi.project_id = p.id)
AND pi.accepted_at IS NULL
AND pi.expires_at > NOW()
) AS pending_invitation_count,
(
SELECT COUNT(*)::BIGINT
FROM core.audit_logs al
WHERE al.project_id = p.id
AND al.created_at >= NOW() - INTERVAL '24 hours'
) AS audit_events_24h,
(
SELECT MAX(al.created_at)::TIMESTAMPTZ
FROM core.audit_logs al
WHERE al.project_id = p.id
) AS last_audit_at
FROM core.projects p
WHERE p.id = $1
`
type GetProjectOverviewRow struct {
ProjectID uuid.UUID `json:"project_id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectSlug string `json:"project_slug"`
ProjectName string `json:"project_name"`
MemberCount int64 `json:"member_count"`
ActiveApiKeyCount int64 `json:"active_api_key_count"`
BucketCount int64 `json:"bucket_count"`
ObjectCount int64 `json:"object_count"`
ObjectBytesTotal int64 `json:"object_bytes_total"`
PendingInvitationCount int64 `json:"pending_invitation_count"`
AuditEvents24h int64 `json:"audit_events_24h"`
LastAuditAt pgtype.Timestamptz `json:"last_audit_at"`
}
func (q *Queries) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) {
row := q.db.QueryRow(ctx, getProjectOverview, id)
var i GetProjectOverviewRow
err := row.Scan(
&i.ProjectID,
&i.OrganizationID,
&i.ProjectSlug,
&i.ProjectName,
&i.MemberCount,
&i.ActiveApiKeyCount,
&i.BucketCount,
&i.ObjectCount,
&i.ObjectBytesTotal,
&i.PendingInvitationCount,
&i.AuditEvents24h,
&i.LastAuditAt,
)
return i, err
}
const listProjectMembers = `-- name: ListProjectMembers :many
SELECT
pm.project_id,
pm.user_id,
pm.role,
pm.created_at,
u.email,
u.name,
u.email_verified
FROM core.project_members pm
JOIN core.users u ON u.id = pm.user_id
WHERE pm.project_id = $1
ORDER BY pm.created_at ASC
`
type ListProjectMembersRow struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) {
rows, err := q.db.Query(ctx, listProjectMembers, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectMembersRow
for rows.Next() {
var i ListProjectMembersRow
if err := rows.Scan(
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.Email,
&i.Name,
&i.EmailVerified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsForOrganization = `-- name: ListProjectsForOrganization :many
SELECT
p.id, p.organization_id, p.slug, p.name, p.description, p.created_at,
pm.role AS membership_role
FROM core.projects p
LEFT JOIN core.project_members pm
ON pm.project_id = p.id
AND pm.user_id = $2
WHERE p.organization_id = $1
AND (
btrim($3) = ''
OR p.slug ILIKE '%' || btrim($3) || '%'
OR p.name ILIKE '%' || btrim($3) || '%'
OR COALESCE(p.description, '') ILIKE '%' || btrim($3) || '%'
)
ORDER BY p.created_at ASC
`
type ListProjectsForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Btrim string `json:"btrim"`
}
type ListProjectsForOrganizationRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
MembershipRole NullCoreProjectRole `json:"membership_role"`
}
func (q *Queries) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) {
rows, err := q.db.Query(ctx, listProjectsForOrganization, arg.OrganizationID, arg.UserID, arg.Btrim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectsForOrganizationRow
for rows.Next() {
var i ListProjectsForOrganizationRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.MembershipRole,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeProjectMember = `-- name: RemoveProjectMember :one
DELETE FROM core.project_members
WHERE project_id = $1
AND user_id = $2
RETURNING id, project_id, user_id, role, created_at
`
type RemoveProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, removeProjectMember, arg.ProjectID, arg.UserID)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const updateProjectByID = `-- name: UpdateProjectByID :one
UPDATE core.projects
SET slug = $2,
name = $3,
description = $4
WHERE id = $1
RETURNING id, organization_id, slug, name, description, created_at
`
type UpdateProjectByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
}
func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error) {
row := q.db.QueryRow(ctx, updateProjectByID,
arg.ID,
arg.Slug,
arg.Name,
arg.Description,
)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const updateProjectMemberRole = `-- name: UpdateProjectMemberRole :one
UPDATE core.project_members
SET role = $3
WHERE project_id = $1
AND user_id = $2
RETURNING id, project_id, user_id, role, created_at
`
type UpdateProjectMemberRoleParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, updateProjectMemberRole, arg.ProjectID, arg.UserID, arg.Role)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"github.com/google/uuid"
)
type Querier interface {
AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error)
AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error)
BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error)
CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error)
CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error)
CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error)
CountOrganizationOwners(ctx context.Context, organizationID uuid.UUID) (int64, error)
CountOrganizations(ctx context.Context) (int64, error)
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error)
ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
}
var _ Querier = (*Queries)(nil)
@@ -0,0 +1,119 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: users.sql
package db
import (
"context"
"github.com/google/uuid"
)
const countOrganizations = `-- name: CountOrganizations :one
SELECT COUNT(*)::BIGINT FROM core.organizations
`
func (q *Queries) CountOrganizations(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countOrganizations)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const getUserByAuthSubject = `-- name: GetUserByAuthSubject :one
SELECT id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at FROM core.users
WHERE auth_subject = $1
`
func (q *Queries) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) {
row := q.db.QueryRow(ctx, getUserByAuthSubject, authSubject)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at FROM core.users
WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
const upsertUser = `-- name: UpsertUser :one
INSERT INTO core.users (
auth_subject,
email,
name,
email_verified,
updated_at,
last_seen_at
) VALUES (
$1,
$2,
$3,
$4,
NOW(),
NOW()
)
ON CONFLICT (auth_subject) DO UPDATE
SET
email = EXCLUDED.email,
name = EXCLUDED.name,
email_verified = EXCLUDED.email_verified,
updated_at = NOW(),
last_seen_at = NOW()
RETURNING id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at
`
type UpsertUserParams struct {
AuthSubject string `json:"auth_subject"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) {
row := q.db.QueryRow(ctx, upsertUser,
arg.AuthSubject,
arg.Email,
arg.Name,
arg.EmailVerified,
)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
// +build integration
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tdvorak/primora/apps/backend/internal/handlers"
"github.com/tdvorak/primora/apps/backend/internal/observability"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/services"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
// Integration tests require a running PostgreSQL instance
// Run with: go test -tags=integration ./...
func TestHealthEndpoints(t *testing.T) {
router := setupTestRouter(t)
t.Run("liveness check", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/health/liveness", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ok", response["status"])
})
t.Run("readiness check", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/health/readiness", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "status")
assert.Contains(t, response, "checks")
})
}
func TestAuthenticationFlow(t *testing.T) {
router := setupTestRouter(t)
t.Run("requires authentication", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("rejects invalid token", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
func TestBootstrapFlow(t *testing.T) {
router := setupTestRouter(t)
// This test requires a valid JWT token
// In a real integration test, you would:
// 1. Create a test user in the auth service
// 2. Get a valid JWT token
// 3. Use that token to test the bootstrap endpoint
t.Skip("Requires auth service integration")
}
func setupTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
// This would need to connect to a test database
// For now, we'll create a minimal setup
router := gin.New()
// Create minimal dependencies
store, err := storage.NewLocalStore(t.TempDir())
require.NoError(t, err)
// In a real integration test, you would:
// - Connect to a test PostgreSQL database
// - Run migrations
// - Create a CoreRepository
// - Create a PlatformService
// For this example, we'll just set up the health endpoints
metrics := observability.NewMetrics()
handler := &handlers.HTTPHandler{
Platform: nil, // Would be initialized with real services
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
return map[string]any{
"status": "ok",
"checks": map[string]any{
"database": "ok",
"storage": "ok",
"dragonfly": "disabled",
},
}
},
}
handler.Register(router)
return router
}
+107
View File
@@ -0,0 +1,107 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
path := "/"
if rawQuery != "" {
path += "?" + rawQuery
}
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, path, nil)
return ctx, recorder
}
func TestParsePaginationQuery(t *testing.T) {
t.Parallel()
t.Run("uses default when absent", func(t *testing.T) {
t.Parallel()
ctx, _ := newTestContext("")
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if !ok {
t.Fatalf("expected parse to succeed")
}
if value != 50 {
t.Fatalf("unexpected value: %d", value)
}
})
t.Run("parses valid query", func(t *testing.T) {
t.Parallel()
ctx, _ := newTestContext("limit=25")
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if !ok {
t.Fatalf("expected parse to succeed")
}
if value != 25 {
t.Fatalf("unexpected value: %d", value)
}
})
t.Run("rejects invalid number", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("limit=abc")
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if ok {
t.Fatalf("expected parse to fail")
}
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("rejects out-of-range value", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("limit=500")
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if ok {
t.Fatalf("expected parse to fail")
}
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
}
func TestHandleErrorStatusMapping(t *testing.T) {
t.Parallel()
handler := &HTTPHandler{}
t.Run("maps insufficient role to forbidden", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("project role admin is insufficient"))
if recorder.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps uniqueness to conflict", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("duplicate key value violates unique constraint"))
if recorder.Code != http.StatusConflict {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps invalid input to bad request", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("invalid invitation project scope"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
}
@@ -0,0 +1,273 @@
package handlers
import (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type organizationMembershipResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
MembershipRole string `json:"membership_role"`
}
type projectResponse struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
MembershipRole *string `json:"membership_role"`
}
type apiKeyResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type bucketResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
}
type bucketObjectResponse struct {
ID string `json:"id"`
BucketID string `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSHA256 string `json:"checksum_sha256"`
CreatedAt time.Time `json:"created_at"`
}
type auditLogResponse struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
RequestID string `json:"request_id"`
Metadata map[string]any `json:"metadata"`
}
type collectionResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema map[string]any `json:"schema"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type documentResponse struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
Data map[string]any `json:"data"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toOrganizationMembershipResponse(row db.ListOrganizationsForUserRow) organizationMembershipResponse {
return organizationMembershipResponse{
ID: row.ID.String(),
Slug: row.Slug,
Name: row.Name,
MembershipRole: row.MembershipRole,
}
}
func toOrganizationMembershipResponseFromCore(row db.CoreOrganization, role string) organizationMembershipResponse {
return organizationMembershipResponse{
ID: row.ID.String(),
Slug: row.Slug,
Name: row.Name,
MembershipRole: role,
}
}
func toProjectListResponse(rows []db.ListProjectsForOrganizationRow) []projectResponse {
result := make([]projectResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toProjectFromRow(row))
}
return result
}
func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
var membershipRole *string
if row.MembershipRole.Valid {
role := string(row.MembershipRole.CoreProjectRole)
membershipRole = &role
}
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: membershipRole,
}
}
func toProjectFromCore(row db.CoreProject) projectResponse {
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: nil,
}
}
func toAPIKeyListResponse(rows []db.CoreApiKey) []apiKeyResponse {
result := make([]apiKeyResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toAPIKeyResponse(row))
}
return result
}
func toAPIKeyResponse(row db.CoreApiKey) apiKeyResponse {
return apiKeyResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Prefix: row.Prefix,
LastUsedAt: timestamptzPtr(row.LastUsedAt),
RevokedAt: timestamptzPtr(row.RevokedAt),
}
}
func toBucketListResponse(rows []db.CoreBucket) []bucketResponse {
result := make([]bucketResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toBucketResponse(row))
}
return result
}
func toBucketResponse(row db.CoreBucket) bucketResponse {
return bucketResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Slug: row.Slug,
Name: row.Name,
Visibility: row.Visibility,
}
}
func toObjectListResponse(rows []db.CoreBucketObject) []bucketObjectResponse {
result := make([]bucketObjectResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toObjectResponse(row))
}
return result
}
func toObjectResponse(row db.CoreBucketObject) bucketObjectResponse {
createdAt := time.Time{}
if row.CreatedAt.Valid {
createdAt = row.CreatedAt.Time
}
return bucketObjectResponse{
ID: row.ID.String(),
BucketID: row.BucketID.String(),
ObjectKey: row.ObjectKey,
ContentType: row.ContentType,
SizeBytes: row.SizeBytes,
ChecksumSHA256: row.ChecksumSha256,
CreatedAt: createdAt,
}
}
func toAuditLogListResponse(rows []db.CoreAuditLog) []auditLogResponse {
result := make([]auditLogResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toAuditLogResponse(row))
}
return result
}
func toAuditLogResponse(row db.CoreAuditLog) auditLogResponse {
metadata := map[string]any{}
_ = json.Unmarshal(row.Metadata, &metadata)
createdAt := time.Time{}
if row.CreatedAt.Valid {
createdAt = row.CreatedAt.Time
}
return auditLogResponse{
ID: row.ID.String(),
CreatedAt: createdAt,
Action: row.Action,
ResourceType: row.ResourceType,
ResourceID: row.ResourceID,
RequestID: row.RequestID,
Metadata: metadata,
}
}
func toCollectionListResponse(rows []db.CoreCollection) []collectionResponse {
result := make([]collectionResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toCollectionResponse(row))
}
return result
}
func toCollectionResponse(row db.CoreCollection) collectionResponse {
schema := map[string]any{}
_ = json.Unmarshal(row.Schema, &schema)
return collectionResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
Schema: schema,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func toDocumentListResponse(rows []db.CoreDocument) []documentResponse {
result := make([]documentResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toDocumentResponse(row))
}
return result
}
func toDocumentResponse(row db.CoreDocument) documentResponse {
data := map[string]any{}
_ = json.Unmarshal(row.Data, &data)
return documentResponse{
ID: row.ID.String(),
CollectionID: row.CollectionID.String(),
Data: data,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func timestamptzPtr(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
t := value.Time
return &t
}
@@ -0,0 +1,51 @@
package middleware
import (
"compress/gzip"
"io"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
type gzipWriter struct {
gin.ResponseWriter
writer *gzip.Writer
}
func (g *gzipWriter) Write(data []byte) (int, error) {
return g.writer.Write(data)
}
func Compression() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "gzip") {
c.Next()
return
}
// Skip compression for small responses or streaming
if c.Request.Method == "HEAD" || c.Request.URL.Path == "/api/v1/health/liveness" {
c.Next()
return
}
gz := gzipPool.Get().(*gzip.Writer)
defer gzipPool.Put(gz)
gz.Reset(c.Writer)
defer gz.Close()
c.Header("Content-Encoding", "gzip")
c.Header("Vary", "Accept-Encoding")
c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz}
c.Next()
}
}
+264
View File
@@ -0,0 +1,264 @@
package middleware
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/primora/apps/backend/internal/auth"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
)
const (
requestIDKey = "request_id"
actorKey = "actor"
)
type AuthMiddleware struct {
Queries *repositories.CoreRepository
Logger *slog.Logger
Redis *redis.Client
Verifier *auth.Verifier
RateLimits RateLimitConfig
}
type RateLimitConfig struct {
APIKeyPerMinute int
UserPerMinute int
}
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.Request.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(requestIDKey, requestID)
c.Writer.Header().Set("X-Request-ID", requestID)
c.Next()
}
}
func Logger(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request_complete",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(c),
"client_ip", c.ClientIP(),
)
}
}
func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := strings.TrimSpace(c.GetHeader("X-API-Key"))
authz := strings.TrimSpace(c.GetHeader("Authorization"))
if apiKey == "" && strings.HasPrefix(strings.ToLower(authz), "bearer ") {
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
if strings.HasPrefix(authz, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(authz, "Bearer "))
}
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
apiKey = ""
actor, err := m.resolveJWTActor(c.Request.Context(), token)
if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_token", err.Error())
return
}
userIdentity := actor.AuthSubject
if actor.UserID != nil {
userIdentity = actor.UserID.String()
}
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
return
}
c.Set(actorKey, actor)
c.Next()
return
}
}
if apiKey == "" {
apiKey = strings.TrimSpace(strings.TrimPrefix(authz, "ApiKey "))
}
if apiKey != "" {
actor, err := m.resolveAPIKeyActor(c.Request.Context(), apiKey)
if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
return
}
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
return
}
c.Set(actorKey, actor)
}
c.Next()
}
}
func (m AuthMiddleware) enforceRateLimit(c *gin.Context, scope, identity string, limit int, exceededMessage string) bool {
if m.Redis == nil || limit <= 0 || identity == "" {
return true
}
key := "ratelimit:" + scope + ":" + identity + ":" + time.Now().UTC().Format("200601021504")
count, err := m.Redis.Incr(c.Request.Context(), key).Result()
if err != nil {
if m.Logger != nil {
m.Logger.Warn("rate limit increment failed", "scope", scope, "error", err)
}
return true
}
if count == 1 {
if err := m.Redis.Expire(c.Request.Context(), key, time.Minute).Err(); err != nil && m.Logger != nil {
m.Logger.Warn("rate limit expiry update failed", "scope", scope, "error", err)
}
}
ttl, err := m.Redis.TTL(c.Request.Context(), key).Result()
if err != nil {
if m.Logger != nil {
m.Logger.Warn("rate limit ttl lookup failed", "scope", scope, "error", err)
}
ttl = time.Minute
}
if ttl <= 0 {
ttl = time.Minute
}
resetSeconds := int((ttl + time.Second - 1) / time.Second)
if resetSeconds < 1 {
resetSeconds = 1
}
remaining := limit - int(count)
if remaining < 0 {
remaining = 0
}
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Header("X-RateLimit-Reset", strconv.Itoa(resetSeconds))
if count > int64(limit) {
c.Header("Retry-After", strconv.Itoa(resetSeconds))
apperrors.Abort(c, http.StatusTooManyRequests, "rate_limited", exceededMessage)
return false
}
return true
}
func RequireActor(c *gin.Context) (*models.Actor, bool) {
actor, ok := ActorFromContext(c)
if !ok {
apperrors.Abort(c, http.StatusUnauthorized, "authentication_required", "authentication is required")
return nil, false
}
return actor, true
}
func ActorFromContext(c *gin.Context) (*models.Actor, bool) {
value, ok := c.Get(actorKey)
if !ok {
return nil, false
}
actor, ok := value.(*models.Actor)
return actor, ok
}
func RequestIDFromContext(c *gin.Context) string {
value, ok := c.Get(requestIDKey)
if !ok {
return ""
}
requestID, _ := value.(string)
return requestID
}
func (m AuthMiddleware) resolveJWTActor(ctx context.Context, token string) (*models.Actor, error) {
if m.Verifier == nil {
return nil, errors.New("jwt verifier not configured")
}
claims, err := m.Verifier.ParseToken(token)
if err != nil {
return nil, err
}
user, err := m.Queries.UpsertUser(ctx, db.UpsertUserParams{
AuthSubject: claims.Subject,
Email: strings.ToLower(claims.Email),
Name: claims.Name,
EmailVerified: claims.EmailVerified,
})
if err != nil {
return nil, fmt.Errorf("upsert user: %w", err)
}
return &models.Actor{
Type: models.ActorTypeUser,
UserID: &user.ID,
AuthSubject: claims.Subject,
Email: strings.ToLower(claims.Email),
EmailVerified: claims.EmailVerified,
Name: claims.Name,
SessionID: claims.SessionID,
}, nil
}
func (m AuthMiddleware) resolveAPIKeyActor(ctx context.Context, rawKey string) (*models.Actor, error) {
parts := strings.Split(rawKey, "_")
if len(parts) < 3 {
return nil, errors.New("malformed api key")
}
prefix := strings.Join(parts[:2], "_")
row, err := m.Queries.GetAPIKeyByPrefix(ctx, prefix)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("unknown api key")
}
return nil, err
}
if row.RevokedAt.Valid {
return nil, errors.New("api key revoked")
}
sum := sha256.Sum256([]byte(rawKey))
if subtle.ConstantTimeCompare(row.SecretHash, sum[:]) != 1 {
return nil, errors.New("invalid api key secret")
}
if err := m.Queries.TouchAPIKey(ctx, row.ID); err != nil {
m.Logger.Warn("failed to touch api key", "error", err)
}
projectID := row.ProjectID
orgID := row.OrganizationID
apiKeyID := row.ID
return &models.Actor{
Type: models.ActorTypeAPIKey,
ProjectID: &projectID,
OrganizationID: &orgID,
APIKeyID: &apiKeyID,
APIKeyPrefix: prefix,
}, nil
}
func PgUUID(id uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: id, Valid: true}
}
func HexDigest(input string) string {
sum := sha256.Sum256([]byte(input))
return hex.EncodeToString(sum[:])
}
+58
View File
@@ -0,0 +1,58 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
func CORS(config CORSConfig) gin.HandlerFunc {
if len(config.AllowedMethods) == 0 {
config.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
}
if len(config.AllowedHeaders) == 0 {
config.AllowedHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-API-Key", "X-Request-ID"}
}
if config.MaxAge == 0 {
config.MaxAge = 86400
}
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
// Check if origin is allowed
allowed := false
for _, allowedOrigin := range config.AllowedOrigins {
if allowedOrigin == "*" || allowedOrigin == origin {
allowed = true
break
}
}
if allowed {
if origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
} else if len(config.AllowedOrigins) == 1 {
c.Writer.Header().Set("Access-Control-Allow-Origin", config.AllowedOrigins[0])
}
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
c.Writer.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
c.Writer.Header().Set("Access-Control-Max-Age", string(rune(config.MaxAge)))
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
@@ -0,0 +1,58 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// StructuredLogger returns a middleware that logs HTTP requests with structured logging
func StructuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
req := c.Request
// Create a logger with request context
logger := log.With().
Str("method", req.Method).
Str("uri", req.RequestURI).
Str("remote_ip", c.ClientIP()).
Str("user_agent", req.UserAgent()).
Str("request_id", req.Header.Get("X-Request-ID")).
Logger()
// Add logger to context
c.Set("logger", &logger)
// Process request
c.Next()
// Calculate latency
latency := time.Since(start)
// Log the request
logEvent := logger.Info()
if len(c.Errors) > 0 {
logEvent = logger.Error().Err(c.Errors.Last())
}
logEvent.
Int("status", c.Writer.Status()).
Int64("bytes_out", int64(c.Writer.Size())).
Dur("latency_ms", latency).
Msg("HTTP request")
}
}
// GetLogger retrieves the logger from the Gin context
func GetLogger(c *gin.Context) *zerolog.Logger {
if logger, exists := c.Get("logger"); exists {
if l, ok := logger.(*zerolog.Logger); ok {
return l
}
}
return &log.Logger
}
@@ -0,0 +1,22 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/primora/apps/backend/internal/observability"
)
func Metrics(metrics *observability.Metrics) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
metrics.IncrementActive()
defer metrics.DecrementActive()
c.Next()
duration := time.Since(start)
isError := c.Writer.Status() >= 400
metrics.RecordRequest(duration, isError)
}
}
@@ -0,0 +1,152 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// RateLimiter implements a simple token bucket rate limiter
type RateLimiter struct {
visitors map[string]*visitor
mu sync.RWMutex
rate int // requests per window
window time.Duration // time window
}
type visitor struct {
tokens int
lastSeen time.Time
mu sync.Mutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: rate,
window: window,
}
// Cleanup old visitors every minute
go rl.cleanupVisitors()
return rl
}
// Middleware returns a Gin middleware function
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.allow(ip) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
return
}
c.Next()
}
}
// allow checks if a request from the given IP is allowed
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
v, exists := rl.visitors[ip]
if !exists {
v = &visitor{
tokens: rl.rate,
lastSeen: time.Now(),
}
rl.visitors[ip] = v
}
rl.mu.Unlock()
v.mu.Lock()
defer v.mu.Unlock()
// Refill tokens based on time passed
now := time.Now()
elapsed := now.Sub(v.lastSeen)
v.lastSeen = now
// Add tokens based on elapsed time
tokensToAdd := int(elapsed / rl.window * time.Duration(rl.rate))
v.tokens += tokensToAdd
if v.tokens > rl.rate {
v.tokens = rl.rate
}
// Check if we have tokens available
if v.tokens > 0 {
v.tokens--
return true
}
return false
}
// cleanupVisitors removes old visitors periodically
func (rl *RateLimiter) cleanupVisitors() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for ip, v := range rl.visitors {
v.mu.Lock()
if time.Since(v.lastSeen) > rl.window*2 {
delete(rl.visitors, ip)
}
v.mu.Unlock()
}
rl.mu.Unlock()
}
}
// RateLimitByKey implements rate limiting by custom key (e.g., user ID, API key)
type KeyRateLimiter struct {
limiters map[string]*RateLimiter
mu sync.RWMutex
rate int
window time.Duration
}
// NewKeyRateLimiter creates a new key-based rate limiter
func NewKeyRateLimiter(rate int, window time.Duration) *KeyRateLimiter {
return &KeyRateLimiter{
limiters: make(map[string]*RateLimiter),
rate: rate,
window: window,
}
}
// Middleware returns a Gin middleware function that rate limits by a custom key
func (krl *KeyRateLimiter) Middleware(keyFunc func(*gin.Context) string) gin.HandlerFunc {
return func(c *gin.Context) {
key := keyFunc(c)
if key == "" {
c.Next()
return
}
krl.mu.RLock()
limiter, exists := krl.limiters[key]
krl.mu.RUnlock()
if !exists {
krl.mu.Lock()
limiter = NewRateLimiter(krl.rate, krl.window)
krl.limiters[key] = limiter
krl.mu.Unlock()
}
if !limiter.allow(key) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded for this resource"})
return
}
c.Next()
}
}
@@ -0,0 +1,38 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
// Recovery returns a middleware that recovers from panics
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
// Log the panic with stack trace
log.Error().
Err(err).
Str("stack", string(debug.Stack())).
Str("method", c.Request.Method).
Str("uri", c.Request.RequestURI).
Str("remote_ip", c.ClientIP()).
Msg("Panic recovered")
// Return internal server error
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
}
}()
c.Next()
}
}
+32
View File
@@ -0,0 +1,32 @@
package models
import "github.com/google/uuid"
type ActorType string
const (
ActorTypeUser ActorType = "user"
ActorTypeAPIKey ActorType = "api_key"
)
type Actor struct {
Type ActorType
UserID *uuid.UUID
AuthSubject string
Email string
EmailVerified bool
Name string
SessionID string
ProjectID *uuid.UUID
OrganizationID *uuid.UUID
APIKeyID *uuid.UUID
APIKeyPrefix string
}
func (a *Actor) IsUser() bool {
return a != nil && a.Type == ActorTypeUser
}
func (a *Actor) IsAPIKey() bool {
return a != nil && a.Type == ActorTypeAPIKey
}
@@ -0,0 +1,58 @@
package observability
import (
"sync"
"time"
)
// Metrics provides basic in-memory metrics collection
type Metrics struct {
mu sync.RWMutex
requestCount int64
errorCount int64
totalResponseTime time.Duration
activeRequests int64
}
func NewMetrics() *Metrics {
return &Metrics{}
}
func (m *Metrics) RecordRequest(duration time.Duration, isError bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.requestCount++
m.totalResponseTime += duration
if isError {
m.errorCount++
}
}
func (m *Metrics) IncrementActive() {
m.mu.Lock()
defer m.mu.Unlock()
m.activeRequests++
}
func (m *Metrics) DecrementActive() {
m.mu.Lock()
defer m.mu.Unlock()
m.activeRequests--
}
func (m *Metrics) GetStats() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
avgResponseTime := int64(0)
if m.requestCount > 0 {
avgResponseTime = m.totalResponseTime.Milliseconds() / m.requestCount
}
return map[string]any{
"total_requests": m.requestCount,
"total_errors": m.errorCount,
"active_requests": m.activeRequests,
"avg_response_time_ms": avgResponseTime,
}
}
@@ -0,0 +1,58 @@
package repositories
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type CoreRepository struct {
pool *pgxpool.Pool
queries *db.Queries
}
func NewCoreRepository(pool *pgxpool.Pool) *CoreRepository {
return &CoreRepository{
pool: pool,
queries: db.New(pool),
}
}
func (r *CoreRepository) Queries() *db.Queries {
return r.queries
}
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
if err := fn(r.queries.WithTx(tx)); err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *CoreRepository) UpsertUser(ctx context.Context, params db.UpsertUserParams) (db.CoreUser, error) {
return r.queries.UpsertUser(ctx, params)
}
func (r *CoreRepository) CountOrganizations(ctx context.Context) (int64, error) {
return r.queries.CountOrganizations(ctx)
}
func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (db.GetAPIKeyByPrefixRow, error) {
return r.queries.GetAPIKeyByPrefix(ctx, prefix)
}
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
return r.queries.TouchAPIKey(ctx, id)
}
+17
View File
@@ -0,0 +1,17 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
func Abort(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
},
"status": http.StatusText(status),
})
}
@@ -0,0 +1,100 @@
package services
import (
"context"
"database/sql"
"time"
)
// HealthService provides health check functionality
type HealthService struct {
db *sql.DB
}
// NewHealthService creates a new health service
func NewHealthService(db *sql.DB) *HealthService {
return &HealthService{
db: db,
}
}
// HealthStatus represents the health status of the application
type HealthStatus struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
Checks map[string]CheckResult `json:"checks"`
}
// CheckResult represents the result of a health check
type CheckResult struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Latency time.Duration `json:"latency_ms"`
}
// Check performs all health checks
func (hs *HealthService) Check(ctx context.Context, version string) HealthStatus {
status := HealthStatus{
Status: "healthy",
Timestamp: time.Now(),
Version: version,
Checks: make(map[string]CheckResult),
}
// Database check
dbCheck := hs.checkDatabase(ctx)
status.Checks["database"] = dbCheck
if dbCheck.Status != "healthy" {
status.Status = "unhealthy"
}
// Add more checks as needed
status.Checks["api"] = CheckResult{
Status: "healthy",
Message: "API is responding",
Latency: 0,
}
return status
}
// checkDatabase checks database connectivity
func (hs *HealthService) checkDatabase(ctx context.Context) CheckResult {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := hs.db.PingContext(ctx)
latency := time.Since(start)
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: err.Error(),
Latency: latency,
}
}
return CheckResult{
Status: "healthy",
Message: "Database connection successful",
Latency: latency,
}
}
// Readiness checks if the service is ready to accept traffic
func (hs *HealthService) Readiness(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
err := hs.db.PingContext(ctx)
return err == nil
}
// Liveness checks if the service is alive
func (hs *HealthService) Liveness(ctx context.Context) bool {
// Simple check - if we can execute this, we're alive
return true
}
+63
View File
@@ -0,0 +1,63 @@
package services
import (
"context"
"fmt"
"net/smtp"
"strings"
"github.com/resend/resend-go/v2"
"github.com/tdvorak/primora/apps/backend/internal/config"
)
type Mailer struct {
cfg config.Config
resend *resend.Client
}
func NewMailer(cfg config.Config) *Mailer {
var resendClient *resend.Client
if cfg.ResendAPIKey != "" {
resendClient = resend.NewClient(cfg.ResendAPIKey)
}
return &Mailer{cfg: cfg, resend: resendClient}
}
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
if m.resend != nil {
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
From: m.cfg.MailFrom,
To: []string{toEmail},
Subject: subject,
Text: text,
})
return err
}
address := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
message := strings.Join([]string{
"From: " + m.cfg.MailFrom,
"To: " + toEmail,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
text,
}, "\r\n")
var auth smtp.Auth
if m.cfg.SMTPUser != "" {
auth = smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPassword, m.cfg.SMTPHost)
}
return smtp.SendMail(address, auth, extractEmail(m.cfg.MailFrom), []string{toEmail}, []byte(message))
}
func extractEmail(input string) string {
if start := strings.Index(input, "<"); start >= 0 {
if end := strings.Index(input, ">"); end > start {
return input[start+1 : end]
}
}
return input
}
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type PutResult struct {
Path string
SizeBytes int64
SHA256Digest string
}
type LocalStore struct {
root string
}
func NewLocalStore(root string) (*LocalStore, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create storage root: %w", err)
}
return &LocalStore{root: root}, nil
}
func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return PutResult{}, err
}
dir := filepath.Join(s.root, bucketID)
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, cleanKey)), 0o755); err != nil {
return PutResult{}, fmt.Errorf("create object directory: %w", err)
}
path := filepath.Join(dir, cleanKey)
tmpPath := path + ".tmp"
file, err := os.Create(tmpPath)
if err != nil {
return PutResult{}, fmt.Errorf("create temp object: %w", err)
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
written, err := copyWithContext(ctx, writer, reader)
if err != nil {
_ = os.Remove(tmpPath)
return PutResult{}, err
}
if err := file.Close(); err != nil {
return PutResult{}, fmt.Errorf("close temp object: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return PutResult{}, fmt.Errorf("rename object: %w", err)
}
return PutResult{
Path: path,
SizeBytes: written,
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return nil, "", err
}
path := filepath.Join(s.root, bucketID, cleanKey)
file, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("open object: %w", err)
}
return file, path, nil
}
func (s *LocalStore) Delete(bucketID, objectKey string) error {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return err
}
if err := os.Remove(filepath.Join(s.root, bucketID, cleanKey)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
func (s *LocalStore) Move(bucketID, fromKey, toKey string) (string, error) {
return s.MoveBetweenBuckets(bucketID, bucketID, fromKey, toKey)
}
func (s *LocalStore) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
cleanFrom, err := sanitizeObjectKey(fromKey)
if err != nil {
return "", err
}
cleanTo, err := sanitizeObjectKey(toKey)
if err != nil {
return "", err
}
fromPath := filepath.Join(s.root, strings.TrimSpace(sourceBucketID), cleanFrom)
toPath := filepath.Join(s.root, strings.TrimSpace(destinationBucketID), cleanTo)
if err := os.MkdirAll(filepath.Dir(toPath), 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
if err := os.Rename(fromPath, toPath); err != nil {
return "", fmt.Errorf("move object: %w", err)
}
return toPath, nil
}
func (s *LocalStore) DeleteBucket(bucketID string) error {
bucketPath := filepath.Join(s.root, strings.TrimSpace(bucketID))
if err := os.RemoveAll(bucketPath); err != nil {
return fmt.Errorf("delete bucket directory: %w", err)
}
return nil
}
func sanitizeObjectKey(key string) (string, error) {
clean := filepath.Clean(strings.TrimSpace(key))
if clean == "." || clean == "" || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.HasPrefix(clean, "/") {
return "", fmt.Errorf("invalid object key")
}
return clean, nil
}
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
buffer := make([]byte, 32*1024)
var written int64
for {
select {
case <-ctx.Done():
return written, ctx.Err()
default:
}
nr, er := src.Read(buffer)
if nr > 0 {
nw, ew := dst.Write(buffer[:nr])
written += int64(nw)
if ew != nil {
return written, ew
}
if nr != nw {
return written, io.ErrShortWrite
}
}
if er != nil {
if er == io.EOF {
return written, nil
}
return written, er
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
)
func TestSanitizeObjectKey(t *testing.T) {
t.Parallel()
valid, err := sanitizeObjectKey(" docs/readme.txt ")
if err != nil {
t.Fatalf("expected valid key, got error: %v", err)
}
if valid != "docs/readme.txt" {
t.Fatalf("unexpected sanitized key: %s", valid)
}
invalidKeys := []string{"", ".", "../secret", "/absolute/path", " /../../etc "}
for _, key := range invalidKeys {
if _, err := sanitizeObjectKey(key); err == nil {
t.Fatalf("expected key %q to be invalid", key)
}
}
}
func TestLocalStorePutOpenDeleteRoundTrip(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("hello primora")
put, err := store.Put(context.Background(), "bucket-a", "docs/hello.txt", bytes.NewReader(content))
if err != nil {
t.Fatalf("put object: %v", err)
}
if put.SizeBytes != int64(len(content)) {
t.Fatalf("unexpected size: %d", put.SizeBytes)
}
expectedDigest := sha256.Sum256(content)
if put.SHA256Digest != hex.EncodeToString(expectedDigest[:]) {
t.Fatalf("unexpected digest: %s", put.SHA256Digest)
}
file, path, err := store.Open("bucket-a", "docs/hello.txt")
if err != nil {
t.Fatalf("open object: %v", err)
}
defer file.Close()
if filepath.Clean(path) != filepath.Clean(put.Path) {
t.Fatalf("path mismatch: got %s, want %s", path, put.Path)
}
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("content mismatch: got %q, want %q", string(data), string(content))
}
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
t.Fatalf("delete object: %v", err)
}
if _, _, err := store.Open("bucket-a", "docs/hello.txt"); err == nil {
t.Fatalf("expected open to fail after delete")
}
}
func TestLocalStoreDeleteBucketRemovesAllFiles(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
if _, err := store.Put(context.Background(), "bucket-z", "a.txt", bytes.NewReader([]byte("a"))); err != nil {
t.Fatalf("put a.txt: %v", err)
}
if _, err := store.Put(context.Background(), "bucket-z", "nested/b.txt", bytes.NewReader([]byte("b"))); err != nil {
t.Fatalf("put b.txt: %v", err)
}
if err := store.DeleteBucket("bucket-z"); err != nil {
t.Fatalf("delete bucket: %v", err)
}
_, err = os.Stat(filepath.Join(root, "bucket-z"))
if !os.IsNotExist(err) {
t.Fatalf("expected bucket path to be removed, stat err: %v", err)
}
}
func TestLocalStoreMoveObject(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("rename me")
if _, err := store.Put(context.Background(), "bucket-r", "source/name.txt", bytes.NewReader(content)); err != nil {
t.Fatalf("put source object: %v", err)
}
newPath, err := store.Move("bucket-r", "source/name.txt", "dest/renamed.txt")
if err != nil {
t.Fatalf("move object: %v", err)
}
if filepath.Clean(newPath) != filepath.Clean(filepath.Join(root, "bucket-r", "dest/renamed.txt")) {
t.Fatalf("unexpected moved path: %s", newPath)
}
if _, _, err := store.Open("bucket-r", "source/name.txt"); err == nil {
t.Fatalf("expected old key open to fail after move")
}
file, _, err := store.Open("bucket-r", "dest/renamed.txt")
if err != nil {
t.Fatalf("open moved object: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read moved object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("moved content mismatch: got %q, want %q", string(data), string(content))
}
}
func TestLocalStoreMoveObjectAcrossBuckets(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("cross bucket")
if _, err := store.Put(context.Background(), "bucket-src", "folder/source.txt", bytes.NewReader(content)); err != nil {
t.Fatalf("put source object: %v", err)
}
newPath, err := store.MoveBetweenBuckets("bucket-src", "bucket-dst", "folder/source.txt", "archive/destination.txt")
if err != nil {
t.Fatalf("move across buckets: %v", err)
}
if filepath.Clean(newPath) != filepath.Clean(filepath.Join(root, "bucket-dst", "archive/destination.txt")) {
t.Fatalf("unexpected moved path: %s", newPath)
}
if _, _, err := store.Open("bucket-src", "folder/source.txt"); err == nil {
t.Fatalf("expected source key open to fail after move")
}
file, _, err := store.Open("bucket-dst", "archive/destination.txt")
if err != nil {
t.Fatalf("open moved object: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read moved object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("moved content mismatch: got %q, want %q", string(data), string(content))
}
}
@@ -0,0 +1,178 @@
package validation
import (
"fmt"
"regexp"
"strings"
)
var (
// SlugRegex matches valid slugs (lowercase alphanumeric with hyphens)
SlugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// EmailRegex matches valid email addresses
EmailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// BucketNameRegex matches valid bucket names
BucketNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
)
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Error implements the error interface
func (ve ValidationError) Error() string {
return fmt.Sprintf("%s: %s", ve.Field, ve.Message)
}
// ValidationErrors is a collection of validation errors
type ValidationErrors []ValidationError
// Error implements the error interface
func (ve ValidationErrors) Error() string {
if len(ve) == 0 {
return ""
}
var messages []string
for _, err := range ve {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}
// Validator provides validation functions
type Validator struct {
errors ValidationErrors
}
// New creates a new validator
func New() *Validator {
return &Validator{
errors: make(ValidationErrors, 0),
}
}
// Required checks if a field is not empty
func (v *Validator) Required(field, value string) *Validator {
if strings.TrimSpace(value) == "" {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "is required",
})
}
return v
}
// MinLength checks if a field meets minimum length
func (v *Validator) MinLength(field, value string, min int) *Validator {
if len(value) < min {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must be at least %d characters", min),
})
}
return v
}
// MaxLength checks if a field doesn't exceed maximum length
func (v *Validator) MaxLength(field, value string, max int) *Validator {
if len(value) > max {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must not exceed %d characters", max),
})
}
return v
}
// Email checks if a field is a valid email
func (v *Validator) Email(field, value string) *Validator {
if value != "" && !EmailRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid email address",
})
}
return v
}
// Slug checks if a field is a valid slug
func (v *Validator) Slug(field, value string) *Validator {
if value != "" && !SlugRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid slug (lowercase letters, numbers, and hyphens)",
})
}
return v
}
// BucketName checks if a field is a valid bucket name
func (v *Validator) BucketName(field, value string) *Validator {
if value != "" {
if !BucketNameRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid bucket name (lowercase letters, numbers, and hyphens, cannot start or end with hyphen)",
})
}
if len(value) < 3 || len(value) > 63 {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be between 3 and 63 characters",
})
}
}
return v
}
// OneOf checks if a field value is one of the allowed values
func (v *Validator) OneOf(field, value string, allowed []string) *Validator {
if value != "" {
found := false
for _, a := range allowed {
if value == a {
found = true
break
}
}
if !found {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must be one of: %s", strings.Join(allowed, ", ")),
})
}
}
return v
}
// Custom adds a custom validation error
func (v *Validator) Custom(field, message string) *Validator {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: message,
})
return v
}
// Valid returns true if there are no validation errors
func (v *Validator) Valid() bool {
return len(v.errors) == 0
}
// Errors returns all validation errors
func (v *Validator) Errors() ValidationErrors {
return v.errors
}
// FirstError returns the first validation error or nil
func (v *Validator) FirstError() error {
if len(v.errors) > 0 {
return v.errors[0]
}
return nil
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
version: "2"
sql:
- engine: "postgresql"
schema:
- "db/migrations"
queries:
- "db/queries"
gen:
go:
package: "db"
out: "internal/database/db"
sql_package: "pgx/v5"
emit_interface: true
emit_json_tags: true
emit_pointers_for_null_types: true
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "core.org_role"
go_type: "string"
- db_type: "core.project_role"
go_type: "string"
- db_type: "core.bucket_visibility"
go_type: "string"