Skip to content

Commit d2ef9b0

Browse files
committed
Add basic consents
1 parent 38e5c74 commit d2ef9b0

32 files changed

+3304
-214
lines changed

backend/gqlgen.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ schema:
1212
- ../gql/roles.graphqls
1313
- ../gql/churches.graphqls
1414
- ../gql/scoring.graphqls
15+
- ../gql/consents.graphqls
1516

1617
# Generated code locations
1718
exec:
@@ -42,6 +43,9 @@ models:
4243
HTML:
4344
model:
4445
- github.com/bcc-media/wayfarer/internal/graph/scalars.HTML
46+
Markdown:
47+
model:
48+
- github.com/bcc-media/wayfarer/internal/graph/scalars.Markdown
4549
Upload:
4650
model:
4751
- github.com/99designs/gqlgen/graphql.Upload
@@ -226,6 +230,22 @@ models:
226230
AwardedByID:
227231
type: "*string"
228232

233+
Consent:
234+
fields:
235+
body:
236+
resolver: true
237+
extraFields:
238+
BodyMarkdown:
239+
type: string
240+
241+
UserConsent:
242+
fields:
243+
consent:
244+
resolver: true
245+
extraFields:
246+
ConsentID:
247+
type: string
248+
229249
# Autobind - automatically bind Go types to GraphQL types if they match by name
230250
autobind:
231251
- github.com/bcc-media/wayfarer/internal/graph/api/model

backend/internal/cache/keys.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ const (
7070

7171
// Translations
7272
PrefixTranslation = "translation:"
73+
74+
// Consents
75+
PrefixConsent = "consent:"
76+
PrefixUserConsents = "userconsents:"
77+
PrefixLatestConsents = "latestconsents"
7378
)
7479

7580
// Key builders for different entity types
@@ -1014,3 +1019,18 @@ func FullLeaderboardKey(context, contextID, entityType string, params map[string
10141019
func TranslationKey(entityType, entityID, langCode string) string {
10151020
return fmt.Sprintf("%s%s:%s:%s", PrefixTranslation, entityType, entityID, langCode)
10161021
}
1022+
1023+
// ConsentKey builds a cache key for a consent by ID
1024+
func ConsentKey(consentID string) string {
1025+
return PrefixConsent + consentID
1026+
}
1027+
1028+
// UserConsentsKey builds a cache key for a user's accepted consents
1029+
func UserConsentsKey(userID string) string {
1030+
return PrefixUserConsents + userID
1031+
}
1032+
1033+
// LatestConsentsKey builds a cache key for all latest published consents
1034+
func LatestConsentsKey() string {
1035+
return PrefixLatestConsents
1036+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
-- Consent definitions (English text in main table)
5+
-- key: identifies consent type (e.g., "privacy_policy", "terms_of_service")
6+
-- version: increments per key for tracking consent updates
7+
-- published_at: null = draft, non-null = active consent
8+
CREATE TABLE consents (
9+
id CHAR(28) PRIMARY KEY CHECK (id ~ '^CN[0-9A-Z]{26}$'),
10+
key VARCHAR(100) NOT NULL,
11+
version INT NOT NULL DEFAULT 1,
12+
title VARCHAR(255) NOT NULL,
13+
body TEXT NOT NULL,
14+
published_at TIMESTAMPTZ,
15+
created_at TIMESTAMPTZ DEFAULT now(),
16+
updated_at TIMESTAMPTZ DEFAULT now(),
17+
UNIQUE (key, version)
18+
);
19+
20+
CREATE INDEX idx_consents_key ON consents(key);
21+
CREATE INDEX idx_consents_published ON consents(published_at) WHERE published_at IS NOT NULL;
22+
23+
-- Consent translations (shadow table pattern)
24+
CREATE TABLE consent_translations (
25+
consent_id CHAR(28) NOT NULL REFERENCES consents(id) ON DELETE CASCADE,
26+
language_code VARCHAR(10) NOT NULL,
27+
title VARCHAR(255),
28+
body TEXT,
29+
created_at TIMESTAMPTZ DEFAULT now(),
30+
updated_at TIMESTAMPTZ DEFAULT now(),
31+
PRIMARY KEY (consent_id, language_code)
32+
);
33+
34+
-- User consent acceptances
35+
-- ON DELETE RESTRICT for consent_id: prevent deleting consents that users have accepted
36+
CREATE TABLE user_consents (
37+
id CHAR(28) PRIMARY KEY CHECK (id ~ '^UC[0-9A-Z]{26}$'),
38+
user_id CHAR(28) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
39+
consent_id CHAR(28) NOT NULL REFERENCES consents(id) ON DELETE RESTRICT,
40+
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41+
created_at TIMESTAMPTZ DEFAULT now(),
42+
UNIQUE (user_id, consent_id)
43+
);
44+
45+
CREATE INDEX idx_user_consents_user ON user_consents(user_id);
46+
CREATE INDEX idx_user_consents_consent ON user_consents(consent_id);
47+
48+
-- +goose StatementEnd
49+
50+
-- +goose Down
51+
-- +goose StatementBegin
52+
53+
DROP TABLE IF EXISTS user_consents;
54+
DROP TABLE IF EXISTS consent_translations;
55+
DROP TABLE IF EXISTS consents;
56+
57+
-- +goose StatementEnd
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
-- Consent queries
2+
3+
-- name: GetConsentByID :one
4+
SELECT id, key, version, title, body, published_at, created_at, updated_at
5+
FROM consents WHERE id = @id::text;
6+
7+
-- name: GetConsentsByIDs :many
8+
SELECT id, key, version, title, body, published_at, created_at, updated_at
9+
FROM consents WHERE id = ANY(@ids::text[]);
10+
11+
-- name: GetLatestPublishedConsentByKey :one
12+
SELECT id, key, version, title, body, published_at, created_at, updated_at
13+
FROM consents
14+
WHERE key = @key::text AND published_at IS NOT NULL AND published_at <= now()
15+
ORDER BY version DESC LIMIT 1;
16+
17+
-- name: GetAllLatestPublishedConsents :many
18+
SELECT DISTINCT ON (key) id, key, version, title, body, published_at, created_at, updated_at
19+
FROM consents
20+
WHERE published_at IS NOT NULL AND published_at <= now()
21+
ORDER BY key, version DESC;
22+
23+
-- name: CreateConsent :one
24+
INSERT INTO consents (id, key, version, title, body, published_at)
25+
VALUES (@id::text, @key::text, @version::int, @title::text, @body::text, @published_at)
26+
RETURNING id, key, version, title, body, published_at, created_at, updated_at;
27+
28+
-- name: UpdateConsent :one
29+
UPDATE consents SET
30+
title = CASE WHEN @title::text = '' THEN title ELSE @title::text END,
31+
body = CASE WHEN @body::text = '' THEN body ELSE @body::text END,
32+
published_at = @published_at,
33+
updated_at = now()
34+
WHERE id = @id::text
35+
RETURNING id, key, version, title, body, published_at, created_at, updated_at;
36+
37+
-- name: GetNextVersionForConsentKey :one
38+
SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM consents WHERE key = @key::text;
39+
40+
-- User consent queries
41+
42+
-- name: GetUserConsentsByUserID :many
43+
SELECT uc.id, uc.user_id, uc.consent_id, uc.accepted_at, uc.created_at,
44+
c.key as consent_key, c.version as consent_version
45+
FROM user_consents uc
46+
INNER JOIN consents c ON uc.consent_id = c.id
47+
WHERE uc.user_id = @user_id::text;
48+
49+
-- name: GetUserConsentsByUserIDs :many
50+
SELECT uc.id, uc.user_id, uc.consent_id, uc.accepted_at, uc.created_at,
51+
c.key as consent_key, c.version as consent_version
52+
FROM user_consents uc
53+
INNER JOIN consents c ON uc.consent_id = c.id
54+
WHERE uc.user_id = ANY(@user_ids::text[]);
55+
56+
-- name: GetUserConsentByUserAndConsent :one
57+
SELECT id, user_id, consent_id, accepted_at, created_at
58+
FROM user_consents WHERE user_id = @user_id::text AND consent_id = @consent_id::text;
59+
60+
-- name: CreateUserConsent :one
61+
INSERT INTO user_consents (id, user_id, consent_id, accepted_at)
62+
VALUES (@id::text, @user_id::text, @consent_id::text, @accepted_at)
63+
RETURNING id, user_id, consent_id, accepted_at, created_at;
64+
65+
-- name: GetMissingConsentsForUser :many
66+
SELECT c.id, c.key, c.version, c.title, c.body, c.published_at, c.created_at, c.updated_at
67+
FROM (
68+
SELECT DISTINCT ON (key) id, key, version, title, body, published_at, created_at, updated_at
69+
FROM consents
70+
WHERE published_at IS NOT NULL AND published_at <= now()
71+
ORDER BY key, version DESC
72+
) c
73+
WHERE NOT EXISTS (
74+
SELECT 1 FROM user_consents uc WHERE uc.user_id = @user_id::text AND uc.consent_id = c.id
75+
);
76+
77+
-- Translation queries
78+
79+
-- name: GetConsentTranslationsByIDs :many
80+
SELECT consent_id, language_code, title, body
81+
FROM consent_translations
82+
WHERE consent_id = ANY(@entity_ids::text[])
83+
AND language_code = @language_code::text;
84+
85+
-- name: DeleteConsentTranslations :exec
86+
DELETE FROM consent_translations WHERE consent_id = @consent_id::text;
87+
88+
-- name: UpsertConsentTranslation :one
89+
INSERT INTO consent_translations (consent_id, language_code, title, body)
90+
VALUES (@consent_id::text, @language_code::text, @title, @body)
91+
ON CONFLICT (consent_id, language_code) DO UPDATE SET
92+
title = EXCLUDED.title,
93+
body = EXCLUDED.body,
94+
updated_at = now()
95+
RETURNING consent_id, language_code, title, body, created_at, updated_at;

0 commit comments

Comments
 (0)