mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-06-04 04:23:00 +00:00
initiall commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) || '%'
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user