diff --git a/backend/e2e/consents_admin_test.go b/backend/e2e/consents_admin_test.go new file mode 100644 index 00000000..51f4d8e1 --- /dev/null +++ b/backend/e2e/consents_admin_test.go @@ -0,0 +1,398 @@ +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/bcc-media/wayfarer/e2e/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const adminConsentsQuery = ` + query AdminConsents($projectId: ID) { + adminConsents(projectId: $projectId) { + id + key + version + publishedAt + project { id } + } + } +` + +type adminConsentRow struct { + ID string `json:"id"` + Key string `json:"key"` + Version int `json:"version"` + PublishedAt *string `json:"publishedAt"` + Project *struct { + ID string `json:"id"` + } `json:"project"` +} + +// TestAdminConsentsListing covers the admin listing, which — unlike the +// `consents` query — has to show drafts: they are published from that page. +func TestAdminConsentsListing(t *testing.T) { + ctx := context.Background() + dbMgr, _ := GetTestEnv() + + require.NoError(t, dbMgr.Clean(ctx)) + data, err := dbMgr.Seed(ctx, 42, testutil.DefaultSeedConfig()) + require.NoError(t, err) + require.GreaterOrEqual(t, len(data.ProjectIDs), 2, "scoping needs two projects") + + projectA, projectB := data.ProjectIDs[0], data.ProjectIDs[1] + adminUserID, plainUserID := data.UserIDs[0], data.UserIDs[1] + require.NoError(t, dbMgr.AssignRole(ctx, adminUserID, testutil.RoleAdmin)) + + past := time.Now().Add(-24 * time.Hour) + future := time.Now().Add(24 * time.Hour) + + // A live v1 with a draft v2 on top: the case the old listing got wrong. + liveV1, err := dbMgr.CreateTestConsentVersion(ctx, "privacy_policy", 1, nil, &past) + require.NoError(t, err) + draftV2, err := dbMgr.CreateTestConsentVersion(ctx, "privacy_policy", 2, nil, nil) + require.NoError(t, err) + // A superseded version that should stay hidden. + _, err = dbMgr.CreateTestConsentVersion(ctx, "terms", 1, nil, &past) + require.NoError(t, err) + liveTermsV2, err := dbMgr.CreateTestConsentVersion(ctx, "terms", 2, nil, &past) + require.NoError(t, err) + scheduled, err := dbMgr.CreateTestConsentVersion(ctx, "scheduled_consent", 1, nil, &future) + require.NoError(t, err) + projectAConsent, err := dbMgr.CreateTestConsentVersion(ctx, "photo_consent_a", 1, &projectA, &past) + require.NoError(t, err) + projectBConsent, err := dbMgr.CreateTestConsentVersion(ctx, "photo_consent_b", 1, &projectB, nil) + require.NoError(t, err) + + adminToken, err := testutil.GenerateAdminToken(adminUserID) + require.NoError(t, err) + userToken, err := testutil.GenerateUserToken(plainUserID) + require.NoError(t, err) + + router, cleanup, err := testutil.SetupTestServer(ctx, dbMgr) + require.NoError(t, err) + defer cleanup() + + client := testutil.NewGraphQLClient(router) + defer client.Close() + + list := func(t *testing.T, projectID *string) []adminConsentRow { + t.Helper() + vars := map[string]any{} + if projectID != nil { + vars["projectId"] = *projectID + } + + resp := client.WithAuth(adminToken).MustExecute(t, adminConsentsQuery, vars) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + AdminConsents []adminConsentRow `json:"adminConsents"` + } + require.NoError(t, resp.UnmarshalData(&result)) + return result.AdminConsents + } + + idsOf := func(rows []adminConsentRow) []string { + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + return ids + } + + t.Run("shows the live version and the draft stacked on it", func(t *testing.T) { + ids := idsOf(list(t, nil)) + + assert.Contains(t, ids, liveV1, "the live version must stay visible under a draft") + assert.Contains(t, ids, draftV2, "the draft is the whole point of the admin listing") + }) + + t.Run("includes consents that are only scheduled", func(t *testing.T) { + assert.Contains(t, idsOf(list(t, nil)), scheduled) + }) + + t.Run("hides superseded published versions", func(t *testing.T) { + rows := list(t, nil) + + var termsVersions []int + for _, row := range rows { + if row.Key == "terms" { + termsVersions = append(termsVersions, row.Version) + } + } + + assert.Equal(t, []int{2}, termsVersions, "only the live version of a key is listed") + assert.Contains(t, idsOf(rows), liveTermsV2) + }) + + t.Run("lists every project's consents when unfiltered", func(t *testing.T) { + ids := idsOf(list(t, nil)) + + assert.Contains(t, ids, projectAConsent) + assert.Contains(t, ids, projectBConsent) + }) + + t.Run("a project filter keeps its own consents and the global ones", func(t *testing.T) { + rows := list(t, &projectA) + ids := idsOf(rows) + + assert.Contains(t, ids, projectAConsent, "the project's own consent") + assert.Contains(t, ids, liveV1, "a global consent applies to this project too") + assert.Contains(t, ids, draftV2, "and so does a global draft") + assert.NotContains(t, ids, projectBConsent, "another project's consent must not leak in") + + for _, row := range rows { + if row.ID == projectAConsent { + require.NotNil(t, row.Project) + assert.Equal(t, projectA, row.Project.ID) + } + } + }) + + t.Run("is refused to non-admins", func(t *testing.T) { + resp := client.WithAuth(userToken).MustExecute(t, adminConsentsQuery, map[string]any{}) + + assert.True(t, resp.HasErrors(), "drafts are unpublished text, not public") + }) +} + +// TestArchiveConsent covers retiring a consent, the only way one leaves +// circulation: user_consent_history has to outlive it. +func TestArchiveConsent(t *testing.T) { + ctx := context.Background() + dbMgr, _ := GetTestEnv() + + require.NoError(t, dbMgr.Clean(ctx)) + data, err := dbMgr.Seed(ctx, 42, testutil.DefaultSeedConfig()) + require.NoError(t, err) + + projectID := data.ProjectIDs[0] + require.NoError(t, dbMgr.SetCurrentProject(ctx, projectID)) + + adminUserID, plainUserID := data.UserIDs[0], data.UserIDs[1] + require.NoError(t, dbMgr.AssignRole(ctx, adminUserID, testutil.RoleAdmin)) + + past := time.Now().Add(-24 * time.Hour) + olderPast := time.Now().Add(-48 * time.Hour) + + draft, err := dbMgr.CreateTestConsentVersion(ctx, "draft_to_retire", 1, nil, nil) + require.NoError(t, err) + // Archiving the live v2 must retire the key, not promote v1. + _, err = dbMgr.CreateTestConsentVersion(ctx, "retire_me", 1, nil, &olderPast) + require.NoError(t, err) + liveV2, err := dbMgr.CreateTestConsentVersion(ctx, "retire_me", 2, nil, &past) + require.NoError(t, err) + keepLive, err := dbMgr.CreateTestConsentVersion(ctx, "keep_me", 1, nil, &past) + require.NoError(t, err) + + adminToken, err := testutil.GenerateAdminToken(adminUserID) + require.NoError(t, err) + userToken, err := testutil.GenerateUserToken(plainUserID) + require.NoError(t, err) + + // With the cache: Ristretto's Set is async, and one that has not landed + // would survive the invalidation under test. testCache.Wait() flushes it. + router, testCache, cleanup, err := testutil.SetupTestServerWithCache(ctx, dbMgr) + require.NoError(t, err) + defer cleanup() + + client := testutil.NewGraphQLClient(router) + defer client.Close() + + const archiveMutation = ` + mutation ArchiveConsent($id: ID!) { + archiveConsent(id: $id) { id archivedAt } + } + ` + + archive := func(t *testing.T, id string) *testutil.GraphQLResponse { + t.Helper() + return client.WithAuth(adminToken).MustExecute(t, archiveMutation, map[string]any{"id": id}) + } + + list := func(t *testing.T, includeArchived bool) []adminConsentRow { + t.Helper() + resp := client.WithAuth(adminToken).MustExecute(t, ` + query AdminConsents($includeArchived: Boolean) { + adminConsents(includeArchived: $includeArchived) { + id + key + version + archivedAt + } + } + `, map[string]any{"includeArchived": includeArchived}) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + AdminConsents []adminConsentRow `json:"adminConsents"` + } + require.NoError(t, resp.UnmarshalData(&result)) + return result.AdminConsents + } + + listedIDs := func(rows []adminConsentRow) []string { + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + return ids + } + + pendingKeys := func(t *testing.T) []string { + t.Helper() + resp := client.WithAuth(userToken).MustExecute(t, ` + query PendingConsents { pendingConsents { key version } } + `, map[string]any{}) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + PendingConsents []struct { + Key string `json:"key"` + Version int `json:"version"` + } `json:"pendingConsents"` + } + require.NoError(t, resp.UnmarshalData(&result)) + + keys := make([]string, 0, len(result.PendingConsents)) + for _, consent := range result.PendingConsents { + keys = append(keys, consent.Key) + } + return keys + } + + // The rendered gate reads ConsentStatus, a cached path of its own. + gatedKeys := func(t *testing.T) []string { + t.Helper() + resp := client.WithAuth(userToken).MustExecute(t, ` + query ConsentGate { me { consentStatus { pendingConsents { key } } } } + `, map[string]any{}) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + Me struct { + ConsentStatus struct { + PendingConsents []struct { + Key string `json:"key"` + } `json:"pendingConsents"` + } `json:"consentStatus"` + } `json:"me"` + } + require.NoError(t, resp.UnmarshalData(&result)) + + keys := make([]string, 0, len(result.Me.ConsentStatus.PendingConsents)) + for _, consent := range result.Me.ConsentStatus.PendingConsents { + keys = append(keys, consent.Key) + } + return keys + } + + t.Run("a live consent is pending before it is archived", func(t *testing.T) { + assert.Contains(t, pendingKeys(t), "retire_me") + // Warms the cache, so the next subtest reads a warm one. + assert.Contains(t, gatedKeys(t), "retire_me") + testCache.Wait() + }) + + t.Run("archives a draft", func(t *testing.T) { + resp := archive(t, draft) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + ArchiveConsent struct { + ArchivedAt *string `json:"archivedAt"` + } `json:"archiveConsent"` + } + require.NoError(t, resp.UnmarshalData(&result)) + assert.NotNil(t, result.ArchiveConsent.ArchivedAt) + + assert.NotContains(t, listedIDs(list(t, false)), draft) + assert.Contains(t, listedIDs(list(t, true)), draft, "includeArchived shows it again") + }) + + t.Run("archiving the live version retires the whole key", func(t *testing.T) { + resp := archive(t, liveV2) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + keys := pendingKeys(t) + assert.NotContains(t, keys, "retire_me", "an archived consent stops being asked for") + assert.Contains(t, keys, "keep_me", "other consents are untouched") + + gated := gatedKeys(t) + assert.NotContains(t, gated, "retire_me", "the cached gate must be invalidated too") + assert.Contains(t, gated, "keep_me") + }) + + t.Run("does not resurrect the version underneath", func(t *testing.T) { + for _, row := range list(t, false) { + assert.NotEqual(t, "retire_me", row.Key, + "archiving v2 must not promote v1 back into the listing") + } + }) + + t.Run("refuses to accept an archived consent", func(t *testing.T) { + resp := client.WithAuth(userToken).MustExecute(t, ` + mutation AcceptConsent($consentId: ID!) { + acceptConsent(consentId: $consentId) { id } + } + `, map[string]any{"consentId": liveV2}) + + require.True(t, resp.HasErrors(), "a retired consent takes no new answers") + assert.Contains(t, resp.ErrorMessage(), "archived") + }) + + t.Run("refuses to archive the same consent twice", func(t *testing.T) { + resp := archive(t, liveV2) + + require.True(t, resp.HasErrors()) + assert.Contains(t, resp.ErrorMessage(), "already archived") + }) + + t.Run("unarchiving puts a consent back into circulation", func(t *testing.T) { + resp := client.WithAuth(adminToken).MustExecute(t, ` + mutation UnarchiveConsent($id: ID!) { + unarchiveConsent(id: $id) { id archivedAt } + } + `, map[string]any{"id": liveV2}) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + + var result struct { + UnarchiveConsent struct { + ArchivedAt *string `json:"archivedAt"` + } `json:"unarchiveConsent"` + } + require.NoError(t, resp.UnmarshalData(&result)) + assert.Nil(t, result.UnarchiveConsent.ArchivedAt) + + assert.Contains(t, pendingKeys(t), "retire_me") + }) + + t.Run("keeps the consent history of an archived consent", func(t *testing.T) { + accept := client.WithAuth(userToken).MustExecute(t, ` + mutation AcceptConsent($consentId: ID!) { + acceptConsent(consentId: $consentId) { id } + } + `, map[string]any{"consentId": keepLive}) + require.False(t, accept.HasErrors(), "unexpected error: %s", accept.ErrorMessage()) + + resp := archive(t, keepLive) + require.False(t, resp.HasErrors(), "an answered consent is archivable, unlike deletable") + + count, err := dbMgr.GetUserConsentHistoryCount(ctx, plainUserID, "keep_me") + require.NoError(t, err) + assert.Equal(t, 1, count, "the audit trail outlives the consent") + }) + + t.Run("is refused to non-admins", func(t *testing.T) { + other, err := dbMgr.CreateTestConsentVersion(ctx, "not_yours_to_retire", 1, nil, nil) + require.NoError(t, err) + + resp := client.WithAuth(userToken).MustExecute(t, archiveMutation, map[string]any{"id": other}) + + assert.True(t, resp.HasErrors()) + }) +} diff --git a/backend/e2e/consents_blocked_visibility_test.go b/backend/e2e/consents_blocked_visibility_test.go index a69f32e8..1b9d32e7 100644 --- a/backend/e2e/consents_blocked_visibility_test.go +++ b/backend/e2e/consents_blocked_visibility_test.go @@ -395,4 +395,48 @@ func TestRequiredConsentHidesUserFromOthers(t *testing.T) { ranks, _ := projectBoard(t) assert.Equal(t, map[string]int{acceptedID: 1, childID: 2}, ranks) }) + + // Archiving goes through the mutations with no manual cache invalidation: + // the resolvers have to refresh the boards themselves + setArchived := func(t *testing.T, mutation, consentID string) { + t.Helper() + resp := client.WithAuth(adminToken).MustExecute(t, ` + mutation ($id: ID!) { `+mutation+`(id: $id) { id } } + `, map[string]any{"id": consentID}) + require.False(t, resp.HasErrors(), "unexpected error: %s", resp.ErrorMessage()) + } + + t.Run("archiving a required consent shows everyone it was hiding", func(t *testing.T) { + require.NotEmpty(t, participationV2) + // Warm the caches with the users hidden + ranks, _ := projectBoard(t) + require.NotContains(t, ranks, unansweredID) + unitRanks, _ := unit(t, viewerToken) + require.NotContains(t, unitRanks, unansweredID) + + setArchived(t, "archiveConsent", participationV2) + + // Archiving v2 must not make v1 the blocking version again: the + // rejected user would stay hidden + want := map[string]int{acceptedID: 1, unansweredID: 2, rejectedID: 3, teenID: 4, childID: 5} + ranks, total := projectBoard(t) + assert.Equal(t, want, ranks) + assert.Equal(t, 5, total) + + unitRanks, _ = unit(t, viewerToken) + assert.Equal(t, want, unitRanks) + }) + + t.Run("unarchiving hides them again", func(t *testing.T) { + require.NotEmpty(t, participationV2) + setArchived(t, "unarchiveConsent", participationV2) + + want := map[string]int{acceptedID: 1, childID: 2} + ranks, total := projectBoard(t) + assert.Equal(t, want, ranks) + assert.Equal(t, 2, total) + + unitRanks, _ := unit(t, viewerToken) + assert.Equal(t, want, unitRanks) + }) } diff --git a/backend/e2e/testutil/database.go b/backend/e2e/testutil/database.go index 6cbde68e..362d63be 100644 --- a/backend/e2e/testutil/database.go +++ b/backend/e2e/testutil/database.go @@ -604,6 +604,23 @@ func (m *TestDBManager) CreateTestProjectConsent(ctx context.Context, key string return consentID, nil } +// CreateTestConsentVersion creates one consent row with an explicit version, +// scope and publication time: nil publishedAt is a draft, a future one is +// scheduled. +func (m *TestDBManager) CreateTestConsentVersion(ctx context.Context, key string, version int, projectID *string, publishedAt *time.Time) (string, error) { + consentID := ulid.NewConsentID() + query := ` + INSERT INTO consents (id, key, version, title, short_text, body, published_at, is_remote, project_id) + VALUES ($1, $2, $3, $4, 'Short text for consent', 'Body of the consent document', $5, false, $6) + ` + title := fmt.Sprintf("Test Consent: %s v%d", key, version) + _, err := m.DB.Pool.Exec(ctx, query, consentID, key, version, title, publishedAt, projectID) + if err != nil { + return "", fmt.Errorf("failed to create test consent %s v%d: %w", key, version, err) + } + return consentID, nil +} + // GetUserConsentHistoryCount returns the count of consent history entries for a user and consent key func (m *TestDBManager) GetUserConsentHistoryCount(ctx context.Context, userID, consentKey string) (int, error) { var count int diff --git a/backend/internal/cache/invalidation.go b/backend/internal/cache/invalidation.go index 9c82fe32..d1f7db57 100644 --- a/backend/internal/cache/invalidation.go +++ b/backend/internal/cache/invalidation.go @@ -184,7 +184,7 @@ func extractPrefixes(key string) []string { PrefixUserProjects, PrefixUserEvents, PrefixTeamMembers, PrefixUserRoles, PrefixUserChallengeEnrollments, PrefixUserChallengeCompletions, PrefixUserContentProgress, PrefixUserAchievements, PrefixUserStreakProgress, - PrefixUserConsents, PrefixUserProjectPoints, PrefixActiveChallengesCount, + PrefixUserConsents, PrefixLatestConsents, PrefixUserProjectPoints, PrefixActiveChallengesCount, PrefixUserTeamInProject, PrefixUserEnrolledChallenges, PrefixUserQuizSessionAccess, PrefixUserActiveQuizSession, PrefixUsersFilter, PrefixUsersCount, @@ -448,6 +448,15 @@ func (c *CacheWithRegistry) InvalidateProject(projectID string) { c.DeletePrefix(PrefixGQLResponse) } +// InvalidateConsents drops everything holding consent state. A cached user +// carries an embedded ConsentStatus, and per-user responses embed the gate; a +// mutation only drops the caller's own responses, and the caller is an admin. +func (c *CacheWithRegistry) InvalidateConsents() { + c.DeletePrefix(PrefixLatestConsents) + c.DeletePrefix(PrefixUser) + c.DeletePrefix(PrefixGQLResponse) +} + // InvalidateEvent invalidates all cache entries related to an event func (c *CacheWithRegistry) InvalidateEvent(eventID string) { c.Delete(EventKey(eventID)) diff --git a/backend/internal/cache/invalidation_test.go b/backend/internal/cache/invalidation_test.go index 67bfb162..e0458abc 100644 --- a/backend/internal/cache/invalidation_test.go +++ b/backend/internal/cache/invalidation_test.go @@ -504,3 +504,43 @@ func TestKeyRegistry_ConcurrentRegisterNoDuplicates(t *testing.T) { keys := registry.GetKeys(PrefixUserEnrolledChallenges) assert.ElementsMatch(t, []string{key, otherKey}, keys) } + +// The consent resolvers have always called DeletePrefix(PrefixLatestConsents), +// but the key was not registered under that prefix, so it matched nothing. +func TestKeyRegistry_LatestConsentsKeyIsFoundByPrefix(t *testing.T) { + registry := NewKeyRegistry() + key := LatestConsentsKey("PR01ARZ3NDEKTSV4RRFFQ69G5FA") + + registry.Register(key) + + assert.Equal(t, []string{key}, registry.GetKeys(PrefixLatestConsents), + "DeletePrefix(PrefixLatestConsents) must be able to find the key") +} + +// All three layers hold consent state; none may survive a consent change. +func TestCacheWithRegistry_InvalidateConsents(t *testing.T) { + c, err := NewCacheWithRegistry(DefaultConfig()) + assert.NoError(t, err) + defer c.Close() + + projectID := "PR01ARZ3NDEKTSV4RRFFQ69G5FA" + userID := "US01ARZ3NDEKTSV4RRFFQ69G5FA" + + c.Set(LatestConsentsKey(projectID), "live-set") + c.Set(UserKey(userID), "user-with-consent-status") + c.Set(GQLResponseUserPrefix(userID)+"queryhash:varshash:nb", "cached-gate-response") + c.Set(ProjectKey(projectID), "project-data") + c.Wait() + + c.InvalidateConsents() + + _, ok := c.Get(LatestConsentsKey(projectID)) + assert.False(t, ok, "the project's live consent set must go") + _, ok = c.Get(UserKey(userID)) + assert.False(t, ok, "a cached user still answers with the old ConsentStatus") + _, ok = c.Get(GQLResponseUserPrefix(userID) + "queryhash:varshash:nb") + assert.False(t, ok, "a cached gate response outlives a per-user mutation drop") + + _, ok = c.Get(ProjectKey(projectID)) + assert.True(t, ok, "unrelated entries are left alone") +} diff --git a/backend/internal/database/migrations/00118_add_consent_archived_at.sql b/backend/internal/database/migrations/00118_add_consent_archived_at.sql new file mode 100644 index 00000000..c5f60e8f --- /dev/null +++ b/backend/internal/database/migrations/00118_add_consent_archived_at.sql @@ -0,0 +1,101 @@ +-- +goose Up +-- +goose StatementBegin + +-- Consents are retired, never deleted: user_consent_history has to outlive them. +ALTER TABLE consents + ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_consents_archived ON consents(archived_at) WHERE archived_at IS NOT NULL; + +COMMENT ON COLUMN consents.archived_at IS 'When the consent was retired; NULL means active. A key is live only while its latest published version is not archived'; + +-- Archiving a required consent stops it blocking. Same rule as 00117 otherwise, +-- and it must still match buildConsentStatus and blockingConsentIds, which get +-- it from GetLatestPublishedConsentsForProject. archived_at is tested after the +-- DISTINCT ON, as there: filtering inside would let archiving v2 make v1 the +-- blocking version again. +CREATE OR REPLACE FUNCTION consent_blocked_user_ids(p_project_id CHAR(28)) +RETURNS TABLE (user_id CHAR(28)) +LANGUAGE sql STABLE AS $$ + WITH latest_consents AS ( + SELECT DISTINCT ON (c.key) c.id, c.key, c.is_required, c.is_remote, c.archived_at + FROM consents c + WHERE c.published_at IS NOT NULL + AND c.published_at <= now() + AND (c.project_id IS NULL OR c.project_id = p_project_id) + ORDER BY c.key, c.version DESC + ), + required_versions AS ( + SELECT id, key FROM latest_consents + WHERE is_required AND NOT is_remote + AND archived_at IS NULL + ) + SELECT up.user_id + FROM user_projects up + INNER JOIN users u ON u.id = up.user_id + WHERE up.project_id = p_project_id + AND EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM u.birthdate) >= 13 + AND EXISTS ( + SELECT 1 + FROM required_versions rv + WHERE ( + SELECT uch.action + FROM user_consent_history uch + WHERE uch.user_id = up.user_id + AND uch.consent_key = rv.key + AND uch.consent_id = rv.id + ORDER BY uch.occurred_at DESC + LIMIT 1 + ) IS DISTINCT FROM 'ACCEPTED' + ) +$$; + +COMMENT ON FUNCTION consent_blocked_user_ids(CHAR(28)) IS 'Users of the project who have not accepted the latest version of a required, unarchived consent; hidden from other users'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Back to 00117, before the column it reads goes away +CREATE OR REPLACE FUNCTION consent_blocked_user_ids(p_project_id CHAR(28)) +RETURNS TABLE (user_id CHAR(28)) +LANGUAGE sql STABLE AS $$ + WITH latest_consents AS ( + SELECT DISTINCT ON (c.key) c.id, c.key, c.is_required, c.is_remote + FROM consents c + WHERE c.published_at IS NOT NULL + AND c.published_at <= now() + AND (c.project_id IS NULL OR c.project_id = p_project_id) + ORDER BY c.key, c.version DESC + ), + required_versions AS ( + SELECT id, key FROM latest_consents + WHERE is_required AND NOT is_remote + ) + SELECT up.user_id + FROM user_projects up + INNER JOIN users u ON u.id = up.user_id + WHERE up.project_id = p_project_id + AND EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM u.birthdate) >= 13 + AND EXISTS ( + SELECT 1 + FROM required_versions rv + WHERE ( + SELECT uch.action + FROM user_consent_history uch + WHERE uch.user_id = up.user_id + AND uch.consent_key = rv.key + AND uch.consent_id = rv.id + ORDER BY uch.occurred_at DESC + LIMIT 1 + ) IS DISTINCT FROM 'ACCEPTED' + ) +$$; + +COMMENT ON FUNCTION consent_blocked_user_ids(CHAR(28)) IS 'Users of the project who have not accepted the latest version of a required consent; hidden from other users'; + +DROP INDEX IF EXISTS idx_consents_archived; +ALTER TABLE consents DROP COLUMN IF EXISTS archived_at; + +-- +goose StatementEnd diff --git a/backend/internal/database/queries/consents.sql b/backend/internal/database/queries/consents.sql index b7bd10ee..45a2cd04 100644 --- a/backend/internal/database/queries/consents.sql +++ b/backend/internal/database/queries/consents.sql @@ -1,37 +1,95 @@ -- Consent queries -- name: GetConsentByID :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE id = @id::char(28); -- name: GetConsentsByIDs :many -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE id = ANY(@ids::char(28)[]); -- name: GetLatestPublishedConsentByKey :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +-- Unfiltered by archived_at: the webhook path still records answers an external +-- system reports for a retired consent. +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE key = @key::text AND published_at IS NOT NULL AND published_at <= now() ORDER BY version DESC LIMIT 1; -- name: GetAllLatestPublishedConsents :many -SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at -FROM consents -WHERE published_at IS NOT NULL AND published_at <= now() -ORDER BY key, version DESC; +-- archived_at is filtered OUTSIDE the DISTINCT ON: filtering inside would let +-- archiving v2 resurrect v1 as the live consent. +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + ORDER BY key, version DESC +) c +WHERE c.archived_at IS NULL +ORDER BY c.key; -- name: GetLatestPublishedConsentsForProject :many -- Latest published consents that apply to the given project: the project's own -- consents plus global ones (project_id IS NULL). Consent keys are globally -- unique, so DISTINCT ON (key) cannot hide another project's consent here. -SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at -FROM consents -WHERE published_at IS NOT NULL AND published_at <= now() - AND (project_id IS NULL OR project_id = @project_id::char(28)) -ORDER BY key, version DESC; +-- See GetAllLatestPublishedConsents for why archived_at is filtered outside the +-- DISTINCT ON. +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + AND (project_id IS NULL OR project_id = @project_id::char(28)) + ORDER BY key, version DESC +) c +WHERE c.archived_at IS NULL +ORDER BY c.key; + +-- name: GetAdminConsents :many +-- The latest live version of each key, plus every not-yet-live version (draft +-- or scheduled) as its own row — collapsing to one row per key would hide a +-- draft behind the version it replaces, on the page that publishes it. +-- @project_id NULL applies no filter; set, it adds the global consents. +WITH live AS ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + ORDER BY key, version DESC +), not_live AS ( + SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NULL OR published_at > now() +) +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT * FROM live + UNION ALL + SELECT * FROM not_live +) c +WHERE ( + sqlc.narg('project_id')::char(28) IS NULL + OR c.project_id IS NULL + OR c.project_id = sqlc.narg('project_id')::char(28) + ) + AND (@include_archived::bool OR c.archived_at IS NULL) +ORDER BY c.key, c.version DESC; + +-- name: ArchiveConsent :one +-- Retires a consent. There is no delete: user_consent_history outlives it. +UPDATE consents +SET archived_at = now(), updated_at = now() +WHERE id = @id::char(28) +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at; + +-- name: UnarchiveConsent :one +UPDATE consents +SET archived_at = NULL, updated_at = now() +WHERE id = @id::char(28) +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at; -- name: GetLatestConsentByKey :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE key = @key::text ORDER BY version DESC @@ -40,7 +98,7 @@ LIMIT 1; -- name: CreateConsent :one INSERT INTO consents (id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required) VALUES (@id::text, @key::text, @version::int, @title::text, @short_text::text, @body::text, @url, @published_at, @is_remote::bool, @managed_by, @project_id, @is_required::bool) -RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at; +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at; -- name: UpdateConsent :one UPDATE consents SET @@ -54,7 +112,7 @@ UPDATE consents SET published_at = @published_at, updated_at = now() WHERE id = @id::char(28) -RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at; +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at; -- name: GetNextVersionForConsentKey :one SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM consents WHERE key = @key::text; @@ -124,16 +182,20 @@ ORDER BY user_id, occurred_at DESC; -- Gets the latest consents that the user has never acted upon (no history for -- that version; answers are per version, 00117) SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, - c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at + c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, + c.archived_at FROM ( SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, - is_remote, managed_by, project_id, is_required, created_at, updated_at + is_remote, managed_by, project_id, is_required, created_at, updated_at, + archived_at FROM consents WHERE published_at IS NOT NULL AND published_at <= now() AND (project_id IS NULL OR project_id = @project_id::char(28)) ORDER BY key, version DESC ) c -WHERE NOT EXISTS ( +-- Outside the DISTINCT ON, so archiving cannot promote the version underneath. +WHERE c.archived_at IS NULL +AND NOT EXISTS ( SELECT 1 FROM user_consent_history uch WHERE uch.user_id = @user_id::char(28) AND uch.consent_key = c.key diff --git a/backend/internal/database/sqlc/consents.sql.go b/backend/internal/database/sqlc/consents.sql.go index 697cf736..e4b070de 100644 --- a/backend/internal/database/sqlc/consents.sql.go +++ b/backend/internal/database/sqlc/consents.sql.go @@ -11,10 +11,59 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const ArchiveConsent = `-- name: ArchiveConsent :one +UPDATE consents +SET archived_at = now(), updated_at = now() +WHERE id = $1::char(28) +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at +` + +type ArchiveConsentRow struct { + ID string `json:"id"` + Key string `json:"key"` + Version int32 `json:"version"` + Title string `json:"title"` + ShortText string `json:"short_text"` + Body string `json:"body"` + Url *string `json:"url"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + IsRemote bool `json:"is_remote"` + ManagedBy *string `json:"managed_by"` + ProjectID *string `json:"project_id"` + IsRequired bool `json:"is_required"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +// Retires a consent. There is no delete: user_consent_history outlives it. +func (q *Queries) ArchiveConsent(ctx context.Context, id string) (*ArchiveConsentRow, error) { + row := q.db.QueryRow(ctx, ArchiveConsent, id) + var i ArchiveConsentRow + err := row.Scan( + &i.ID, + &i.Key, + &i.Version, + &i.Title, + &i.ShortText, + &i.Body, + &i.Url, + &i.PublishedAt, + &i.IsRemote, + &i.ManagedBy, + &i.ProjectID, + &i.IsRequired, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ) + return &i, err +} + const CreateConsent = `-- name: CreateConsent :one INSERT INTO consents (id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required) VALUES ($1::text, $2::text, $3::int, $4::text, $5::text, $6::text, $7, $8, $9::bool, $10, $11, $12::bool) -RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at ` type CreateConsentParams struct { @@ -47,6 +96,7 @@ type CreateConsentRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } func (q *Queries) CreateConsent(ctx context.Context, arg CreateConsentParams) (*CreateConsentRow, error) { @@ -80,6 +130,7 @@ func (q *Queries) CreateConsent(ctx context.Context, arg CreateConsentParams) (* &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ) return &i, err } @@ -159,11 +210,105 @@ func (q *Queries) DeleteConsentTranslations(ctx context.Context, consentID strin return err } +const GetAdminConsents = `-- name: GetAdminConsents :many +WITH live AS ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + ORDER BY key, version DESC +), not_live AS ( + SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NULL OR published_at > now() +) +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM live + UNION ALL + SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM not_live +) c +WHERE ( + $1::char(28) IS NULL + OR c.project_id IS NULL + OR c.project_id = $1::char(28) + ) + AND ($2::bool OR c.archived_at IS NULL) +ORDER BY c.key, c.version DESC +` + +type GetAdminConsentsParams struct { + ProjectID *string `json:"project_id"` + IncludeArchived bool `json:"include_archived"` +} + +type GetAdminConsentsRow struct { + ID string `json:"id"` + Key string `json:"key"` + Version int32 `json:"version"` + Title string `json:"title"` + ShortText string `json:"short_text"` + Body string `json:"body"` + Url *string `json:"url"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + IsRemote bool `json:"is_remote"` + ManagedBy *string `json:"managed_by"` + ProjectID *string `json:"project_id"` + IsRequired bool `json:"is_required"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +// The latest live version of each key, plus every not-yet-live version (draft +// or scheduled) as its own row — collapsing to one row per key would hide a +// draft behind the version it replaces, on the page that publishes it. +// @project_id NULL applies no filter; set, it adds the global consents. +func (q *Queries) GetAdminConsents(ctx context.Context, arg GetAdminConsentsParams) ([]*GetAdminConsentsRow, error) { + rows, err := q.db.Query(ctx, GetAdminConsents, arg.ProjectID, arg.IncludeArchived) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*GetAdminConsentsRow{} + for rows.Next() { + var i GetAdminConsentsRow + if err := rows.Scan( + &i.ID, + &i.Key, + &i.Version, + &i.Title, + &i.ShortText, + &i.Body, + &i.Url, + &i.PublishedAt, + &i.IsRemote, + &i.ManagedBy, + &i.ProjectID, + &i.IsRequired, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAllLatestPublishedConsents = `-- name: GetAllLatestPublishedConsents :many -SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at -FROM consents -WHERE published_at IS NOT NULL AND published_at <= now() -ORDER BY key, version DESC +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + ORDER BY key, version DESC +) c +WHERE c.archived_at IS NULL +ORDER BY c.key ` type GetAllLatestPublishedConsentsRow struct { @@ -181,8 +326,11 @@ type GetAllLatestPublishedConsentsRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } +// archived_at is filtered OUTSIDE the DISTINCT ON: filtering inside would let +// archiving v2 resurrect v1 as the live consent. func (q *Queries) GetAllLatestPublishedConsents(ctx context.Context) ([]*GetAllLatestPublishedConsentsRow, error) { rows, err := q.db.Query(ctx, GetAllLatestPublishedConsents) if err != nil { @@ -207,6 +355,7 @@ func (q *Queries) GetAllLatestPublishedConsents(ctx context.Context) ([]*GetAllL &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -293,7 +442,7 @@ func (q *Queries) GetConsentBlockedUsersInProjects(ctx context.Context, projectI const GetConsentByID = `-- name: GetConsentByID :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE id = $1::char(28) ` @@ -312,6 +461,7 @@ type GetConsentByIDRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } // Consent queries @@ -333,6 +483,7 @@ func (q *Queries) GetConsentByID(ctx context.Context, id string) (*GetConsentByI &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ) return &i, err } @@ -386,7 +537,7 @@ func (q *Queries) GetConsentTranslationsByIDs(ctx context.Context, arg GetConsen } const GetConsentsByIDs = `-- name: GetConsentsByIDs :many -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE id = ANY($1::char(28)[]) ` @@ -405,6 +556,7 @@ type GetConsentsByIDsRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } func (q *Queries) GetConsentsByIDs(ctx context.Context, ids []string) ([]*GetConsentsByIDsRow, error) { @@ -431,6 +583,7 @@ func (q *Queries) GetConsentsByIDs(ctx context.Context, ids []string) ([]*GetCon &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -491,7 +644,7 @@ func (q *Queries) GetCurrentUserConsentStatusesByUsers(ctx context.Context, user } const GetLatestConsentByKey = `-- name: GetLatestConsentByKey :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE key = $1::text ORDER BY version DESC @@ -513,6 +666,7 @@ type GetLatestConsentByKeyRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } func (q *Queries) GetLatestConsentByKey(ctx context.Context, key string) (*GetLatestConsentByKeyRow, error) { @@ -533,12 +687,13 @@ func (q *Queries) GetLatestConsentByKey(ctx context.Context, key string) (*GetLa &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ) return &i, err } const GetLatestPublishedConsentByKey = `-- name: GetLatestPublishedConsentByKey :one -SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +SELECT id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at FROM consents WHERE key = $1::text AND published_at IS NOT NULL AND published_at <= now() ORDER BY version DESC LIMIT 1 @@ -559,8 +714,11 @@ type GetLatestPublishedConsentByKeyRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } +// Unfiltered by archived_at: the webhook path still records answers an external +// system reports for a retired consent. func (q *Queries) GetLatestPublishedConsentByKey(ctx context.Context, key string) (*GetLatestPublishedConsentByKeyRow, error) { row := q.db.QueryRow(ctx, GetLatestPublishedConsentByKey, key) var i GetLatestPublishedConsentByKeyRow @@ -579,16 +737,22 @@ func (q *Queries) GetLatestPublishedConsentByKey(ctx context.Context, key string &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ) return &i, err } const GetLatestPublishedConsentsForProject = `-- name: GetLatestPublishedConsentsForProject :many -SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at -FROM consents -WHERE published_at IS NOT NULL AND published_at <= now() - AND (project_id IS NULL OR project_id = $1::char(28)) -ORDER BY key, version DESC +SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, c.archived_at +FROM ( + SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at + FROM consents + WHERE published_at IS NOT NULL AND published_at <= now() + AND (project_id IS NULL OR project_id = $1::char(28)) + ORDER BY key, version DESC +) c +WHERE c.archived_at IS NULL +ORDER BY c.key ` type GetLatestPublishedConsentsForProjectRow struct { @@ -606,11 +770,14 @@ type GetLatestPublishedConsentsForProjectRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } // Latest published consents that apply to the given project: the project's own // consents plus global ones (project_id IS NULL). Consent keys are globally // unique, so DISTINCT ON (key) cannot hide another project's consent here. +// See GetAllLatestPublishedConsents for why archived_at is filtered outside the +// DISTINCT ON. func (q *Queries) GetLatestPublishedConsentsForProject(ctx context.Context, projectID string) ([]*GetLatestPublishedConsentsForProjectRow, error) { rows, err := q.db.Query(ctx, GetLatestPublishedConsentsForProject, projectID) if err != nil { @@ -635,6 +802,7 @@ func (q *Queries) GetLatestPublishedConsentsForProject(ctx context.Context, proj &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -691,16 +859,19 @@ func (q *Queries) GetLatestUserConsentActionByKey(ctx context.Context, arg GetLa const GetMissingConsentsForUserWithRejections = `-- name: GetMissingConsentsForUserWithRejections :many SELECT c.id, c.key, c.version, c.title, c.short_text, c.body, c.url, c.published_at, - c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at + c.is_remote, c.managed_by, c.project_id, c.is_required, c.created_at, c.updated_at, + c.archived_at FROM ( SELECT DISTINCT ON (key) id, key, version, title, short_text, body, url, published_at, - is_remote, managed_by, project_id, is_required, created_at, updated_at + is_remote, managed_by, project_id, is_required, created_at, updated_at, + archived_at FROM consents WHERE published_at IS NOT NULL AND published_at <= now() AND (project_id IS NULL OR project_id = $1::char(28)) ORDER BY key, version DESC ) c -WHERE NOT EXISTS ( +WHERE c.archived_at IS NULL +AND NOT EXISTS ( SELECT 1 FROM user_consent_history uch WHERE uch.user_id = $2::char(28) AND uch.consent_key = c.key @@ -728,10 +899,12 @@ type GetMissingConsentsForUserWithRejectionsRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } // Gets the latest consents that the user has never acted upon (no history for // that version; answers are per version, 00117) +// Outside the DISTINCT ON, so archiving cannot promote the version underneath. func (q *Queries) GetMissingConsentsForUserWithRejections(ctx context.Context, arg GetMissingConsentsForUserWithRejectionsParams) ([]*GetMissingConsentsForUserWithRejectionsRow, error) { rows, err := q.db.Query(ctx, GetMissingConsentsForUserWithRejections, arg.ProjectID, arg.UserID) if err != nil { @@ -756,6 +929,7 @@ func (q *Queries) GetMissingConsentsForUserWithRejections(ctx context.Context, a &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -981,6 +1155,54 @@ func (q *Queries) GetUserConsentHistoryWithTitles(ctx context.Context, userID st return items, nil } +const UnarchiveConsent = `-- name: UnarchiveConsent :one +UPDATE consents +SET archived_at = NULL, updated_at = now() +WHERE id = $1::char(28) +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at +` + +type UnarchiveConsentRow struct { + ID string `json:"id"` + Key string `json:"key"` + Version int32 `json:"version"` + Title string `json:"title"` + ShortText string `json:"short_text"` + Body string `json:"body"` + Url *string `json:"url"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + IsRemote bool `json:"is_remote"` + ManagedBy *string `json:"managed_by"` + ProjectID *string `json:"project_id"` + IsRequired bool `json:"is_required"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +func (q *Queries) UnarchiveConsent(ctx context.Context, id string) (*UnarchiveConsentRow, error) { + row := q.db.QueryRow(ctx, UnarchiveConsent, id) + var i UnarchiveConsentRow + err := row.Scan( + &i.ID, + &i.Key, + &i.Version, + &i.Title, + &i.ShortText, + &i.Body, + &i.Url, + &i.PublishedAt, + &i.IsRemote, + &i.ManagedBy, + &i.ProjectID, + &i.IsRequired, + &i.CreatedAt, + &i.UpdatedAt, + &i.ArchivedAt, + ) + return &i, err +} + const UpdateConsent = `-- name: UpdateConsent :one UPDATE consents SET title = CASE WHEN $1::text = '' THEN title ELSE $1::text END, @@ -993,7 +1215,7 @@ UPDATE consents SET published_at = $7, updated_at = now() WHERE id = $8::char(28) -RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at +RETURNING id, key, version, title, short_text, body, url, published_at, is_remote, managed_by, project_id, is_required, created_at, updated_at, archived_at ` type UpdateConsentParams struct { @@ -1022,6 +1244,7 @@ type UpdateConsentRow struct { IsRequired bool `json:"is_required"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } func (q *Queries) UpdateConsent(ctx context.Context, arg UpdateConsentParams) (*UpdateConsentRow, error) { @@ -1051,6 +1274,7 @@ func (q *Queries) UpdateConsent(ctx context.Context, arg UpdateConsentParams) (* &i.IsRequired, &i.CreatedAt, &i.UpdatedAt, + &i.ArchivedAt, ) return &i, err } diff --git a/backend/internal/database/sqlc/models.go b/backend/internal/database/sqlc/models.go index 2285bea0..64b0edef 100644 --- a/backend/internal/database/sqlc/models.go +++ b/backend/internal/database/sqlc/models.go @@ -141,6 +141,8 @@ type Consent struct { ProjectID *string `json:"project_id"` // Consent must be accepted to take part in the project; cannot be rejected IsRequired bool `json:"is_required"` + // When the consent was retired; NULL means active. A key is live only while its latest published version is not archived + ArchivedAt pgtype.Timestamptz `json:"archived_at"` } type ConsentTranslation struct { diff --git a/backend/internal/graph/api/consents.go b/backend/internal/graph/api/consents.go index 4ffb8384..7e4699be 100644 --- a/backend/internal/graph/api/consents.go +++ b/backend/internal/graph/api/consents.go @@ -10,6 +10,8 @@ import ( "github.com/bcc-media/wayfarer/internal/cache" "github.com/bcc-media/wayfarer/internal/database/sqlc" + "github.com/bcc-media/wayfarer/internal/graph/api/model" + "github.com/bcc-media/wayfarer/internal/graph/scalars" ) // projectUser is a user within one project @@ -218,3 +220,40 @@ func (r *Resolver) requireLatestConsentVersion(ctx context.Context, consentID, k } return latestConsentVersionError(consentID, latest.ID) } + +// consentFromAdminRow converts a listing row into the GraphQL model. +// Translations are applied by the caller. +func consentFromAdminRow(row *sqlc.GetAdminConsentsRow) model.Consent { + var publishedAt *scalars.DateTime + if row.PublishedAt.Valid { + dt := scalars.DateTime{Time: row.PublishedAt.Time} + publishedAt = &dt + } + + var archivedAt *scalars.DateTime + if row.ArchivedAt.Valid { + dt := scalars.DateTime{Time: row.ArchivedAt.Time} + archivedAt = &dt + } + + managementType := model.ConsentManagementTypeLocal + if row.IsRemote { + managementType = model.ConsentManagementTypeRemote + } + + return model.Consent{ + ID: row.ID, + Key: row.Key, + Version: int(row.Version), + Title: row.Title, + ShortText: row.ShortText, + BodyMarkdown: row.Body, + URL: row.Url, + PublishedAt: publishedAt, + ArchivedAt: archivedAt, + ManagementType: managementType, + ManagedBy: row.ManagedBy, + ProjectID: row.ProjectID, + IsRequired: row.IsRequired, + } +} diff --git a/backend/internal/graph/api/consents.resolvers.go b/backend/internal/graph/api/consents.resolvers.go index 5ae55dfc..bdadd02b 100644 --- a/backend/internal/graph/api/consents.resolvers.go +++ b/backend/internal/graph/api/consents.resolvers.go @@ -111,6 +111,11 @@ func (r *mutationResolver) AcceptConsent(ctx context.Context, consentID string) return nil, fmt.Errorf("consent is not published yet") } + // A late answer to a retired consent is a stale client. + if consent.ArchivedAt.Valid { + return nil, fmt.Errorf("consent is archived") + } + // Block if consent is remote (must be accepted externally) if consent.IsRemote { return nil, fmt.Errorf("cannot accept remote consent locally, must be accepted via external system") @@ -187,6 +192,11 @@ func (r *mutationResolver) RejectConsent(ctx context.Context, consentID string) return nil, fmt.Errorf("consent is not published yet") } + // A late answer to a retired consent is a stale client. + if consent.ArchivedAt.Valid { + return nil, fmt.Errorf("consent is archived") + } + // Block if consent is remote (must be rejected externally) if consent.IsRemote { return nil, fmt.Errorf("cannot reject remote consent locally, must be managed via external system") @@ -303,7 +313,7 @@ func (r *mutationResolver) CreateConsent(ctx context.Context, key string, title } // Invalidate latest consents cache - r.Cache.DeletePrefix(cache.PrefixLatestConsents) + r.Cache.InvalidateConsents() r.applyConsentBlockChanges(ctx, blockedBefore, r.blockedUsersInConsentScope(ctx, consentProjectID)) var publishedAtOut *scalars.DateTime @@ -312,6 +322,12 @@ func (r *mutationResolver) CreateConsent(ctx context.Context, key string, title publishedAtOut = &dt } + var archivedAtOut *scalars.DateTime + if consent.ArchivedAt.Valid { + dt := scalars.DateTime{Time: consent.ArchivedAt.Time} + archivedAtOut = &dt + } + managementType := model.ConsentManagementTypeLocal if consent.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -326,6 +342,7 @@ func (r *mutationResolver) CreateConsent(ctx context.Context, key string, title BodyMarkdown: consent.Body, URL: consent.Url, PublishedAt: publishedAtOut, + ArchivedAt: archivedAtOut, ManagementType: managementType, ManagedBy: consent.ManagedBy, ProjectID: consent.ProjectID, @@ -381,7 +398,7 @@ func (r *mutationResolver) UpdateConsent(ctx context.Context, id string, title * // Invalidate caches r.Cache.Delete(cache.ConsentKey(id)) - r.Cache.DeletePrefix(cache.PrefixLatestConsents) + r.Cache.InvalidateConsents() r.applyConsentBlockChanges(ctx, blockedBefore, r.blockedUsersInConsentScope(ctx, existing.ProjectID)) var publishedAtOut *scalars.DateTime @@ -390,6 +407,12 @@ func (r *mutationResolver) UpdateConsent(ctx context.Context, id string, title * publishedAtOut = &dt } + var archivedAtOut *scalars.DateTime + if consent.ArchivedAt.Valid { + dt := scalars.DateTime{Time: consent.ArchivedAt.Time} + archivedAtOut = &dt + } + managementType := model.ConsentManagementTypeLocal if consent.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -404,6 +427,7 @@ func (r *mutationResolver) UpdateConsent(ctx context.Context, id string, title * BodyMarkdown: consent.Body, URL: consent.Url, PublishedAt: publishedAtOut, + ArchivedAt: archivedAtOut, ManagementType: managementType, ManagedBy: consent.ManagedBy, ProjectID: consent.ProjectID, @@ -411,6 +435,53 @@ func (r *mutationResolver) UpdateConsent(ctx context.Context, id string, title * }, nil } +// ArchiveConsent is the resolver for the archiveConsent field. +func (r *mutationResolver) ArchiveConsent(ctx context.Context, id string) (*model.Consent, error) { + existing, err := r.DB.Queries.GetConsentByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("consent not found: %w", err) + } + + if existing.ArchivedAt.Valid { + return nil, fmt.Errorf("consent %q is already archived", existing.Key) + } + + // Archiving a required consent unblocks the users it was holding back + blockedBefore := r.blockedUsersInConsentScope(ctx, existing.ProjectID) + + if _, err := r.DB.Queries.ArchiveConsent(ctx, id); err != nil { + return nil, fmt.Errorf("failed to archive consent: %w", err) + } + + // Before the reload below, which reads through the same cache. + r.Cache.Delete(cache.ConsentKey(id)) + r.Cache.InvalidateConsents() + r.applyConsentBlockChanges(ctx, blockedBefore, r.blockedUsersInConsentScope(ctx, existing.ProjectID)) + + return r.LoadConsentWithTranslation(ctx, id) +} + +// UnarchiveConsent is the resolver for the unarchiveConsent field. +func (r *mutationResolver) UnarchiveConsent(ctx context.Context, id string) (*model.Consent, error) { + existing, err := r.DB.Queries.GetConsentByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("consent not found: %w", err) + } + + // Unarchiving a required consent blocks again whoever has not accepted it + blockedBefore := r.blockedUsersInConsentScope(ctx, existing.ProjectID) + + if _, err := r.DB.Queries.UnarchiveConsent(ctx, id); err != nil { + return nil, fmt.Errorf("failed to unarchive consent: %w", err) + } + + r.Cache.Delete(cache.ConsentKey(id)) + r.Cache.InvalidateConsents() + r.applyConsentBlockChanges(ctx, blockedBefore, r.blockedUsersInConsentScope(ctx, existing.ProjectID)) + + return r.LoadConsentWithTranslation(ctx, id) +} + // AdminSetUserConsent is the resolver for the adminSetUserConsent field. func (r *mutationResolver) AdminSetUserConsent(ctx context.Context, userID string, consentID string, action model.ConsentAction) (*model.UserConsentHistoryEntry, error) { // Get admin user ID from context for audit tracking @@ -504,6 +575,12 @@ func (r *queryResolver) Consents(ctx context.Context) ([]model.Consent, error) { publishedAt = &dt } + var archivedAt *scalars.DateTime + if row.ArchivedAt.Valid { + dt := scalars.DateTime{Time: row.ArchivedAt.Time} + archivedAt = &dt + } + managementType := model.ConsentManagementTypeLocal if row.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -518,6 +595,7 @@ func (r *queryResolver) Consents(ctx context.Context) ([]model.Consent, error) { BodyMarkdown: row.Body, URL: row.Url, PublishedAt: publishedAt, + ArchivedAt: archivedAt, ManagementType: managementType, ManagedBy: row.ManagedBy, ProjectID: row.ProjectID, @@ -531,6 +609,35 @@ func (r *queryResolver) Consents(ctx context.Context) ([]model.Consent, error) { return consents, nil } +// AdminConsents is the resolver for the adminConsents field. +func (r *queryResolver) AdminConsents(ctx context.Context, projectID *string, includeArchived *bool) ([]model.Consent, error) { + userID, ok := middleware.GetUserID(ctx) + if !ok || userID == "" { + return nil, fmt.Errorf("user not authenticated") + } + + // Drafts are unpublished text; @requireRole is kept to mutations here. + if !r.RoleService.IsAdmin(ctx, userID) { + return nil, fmt.Errorf("unauthorized to list consents") + } + + rows, err := r.DB.Queries.GetAdminConsents(ctx, sqlc.GetAdminConsentsParams{ + ProjectID: projectID, + IncludeArchived: includeArchived != nil && *includeArchived, + }) + if err != nil { + return nil, fmt.Errorf("failed to get consents: %w", err) + } + + consents := make([]model.Consent, len(rows)) + for i, row := range rows { + consent := consentFromAdminRow(row) + consents[i] = *r.ApplyTranslationToConsent(ctx, &consent) + } + + return consents, nil +} + // Consent is the resolver for the consent field. func (r *queryResolver) Consent(ctx context.Context, id string) (*model.Consent, error) { return r.LoadConsentWithTranslation(ctx, id) @@ -565,6 +672,12 @@ func (r *queryResolver) PendingConsents(ctx context.Context) ([]model.Consent, e publishedAt = &dt } + var archivedAt *scalars.DateTime + if row.ArchivedAt.Valid { + dt := scalars.DateTime{Time: row.ArchivedAt.Time} + archivedAt = &dt + } + managementType := model.ConsentManagementTypeLocal if row.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -579,6 +692,7 @@ func (r *queryResolver) PendingConsents(ctx context.Context) ([]model.Consent, e BodyMarkdown: row.Body, URL: row.Url, PublishedAt: publishedAt, + ArchivedAt: archivedAt, ManagementType: managementType, ManagedBy: row.ManagedBy, ProjectID: row.ProjectID, diff --git a/backend/internal/graph/api/consents_test.go b/backend/internal/graph/api/consents_test.go index 2879a613..1cfcdb05 100644 --- a/backend/internal/graph/api/consents_test.go +++ b/backend/internal/graph/api/consents_test.go @@ -2,11 +2,13 @@ package api import ( "testing" + "time" + "github.com/bcc-media/wayfarer/internal/database/sqlc" + "github.com/bcc-media/wayfarer/internal/graph/api/model" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/bcc-media/wayfarer/internal/database/sqlc" ) func TestConsentProjectForNewVersion(t *testing.T) { @@ -147,3 +149,76 @@ func TestConsentBlockChangesBetween(t *testing.T) { assert.False(t, afterKnown) }) } + +func TestConsentFromAdminRow(t *testing.T) { + published := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + projectID := "PR01ARZ3NDEKTSV4RRFFQ69G5FA" + url := "https://example.com/personvern" + managedBy := "members" + + t.Run("maps a published local consent", func(t *testing.T) { + got := consentFromAdminRow(&sqlc.GetAdminConsentsRow{ + ID: "CN01ARZ3NDEKTSV4RRFFQ69G5FA", + Key: "privacy_policy", + Version: 2, + Title: "Personvern", + ShortText: "Kort", + Body: "# Personvern", + Url: &url, + PublishedAt: pgtype.Timestamptz{Time: published, Valid: true}, + ProjectID: &projectID, + IsRequired: true, + }) + + assert.Equal(t, "CN01ARZ3NDEKTSV4RRFFQ69G5FA", got.ID) + assert.Equal(t, "privacy_policy", got.Key) + assert.Equal(t, 2, got.Version) + assert.Equal(t, "# Personvern", got.BodyMarkdown) + assert.Equal(t, model.ConsentManagementTypeLocal, got.ManagementType) + require.NotNil(t, got.PublishedAt) + assert.Equal(t, published, got.PublishedAt.Time) + require.NotNil(t, got.ProjectID) + assert.Equal(t, projectID, *got.ProjectID) + assert.True(t, got.IsRequired) + }) + + t.Run("leaves publishedAt nil for a draft", func(t *testing.T) { + got := consentFromAdminRow(&sqlc.GetAdminConsentsRow{ + ID: "CN01ARZ3NDEKTSV4RRFFQ69G5FB", + Key: "photo_consent", + Version: 1, + PublishedAt: pgtype.Timestamptz{}, + }) + + assert.Nil(t, got.PublishedAt, "a draft has no publication date to show") + assert.Nil(t, got.ArchivedAt, "nor an archive date") + assert.Nil(t, got.ProjectID) + }) + + t.Run("carries the archive date of a retired consent", func(t *testing.T) { + archived := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + got := consentFromAdminRow(&sqlc.GetAdminConsentsRow{ + ID: "CN01ARZ3NDEKTSV4RRFFQ69G5FD", + Key: "old_terms", + Version: 1, + PublishedAt: pgtype.Timestamptz{Time: published, Valid: true}, + ArchivedAt: pgtype.Timestamptz{Time: archived, Valid: true}, + }) + + require.NotNil(t, got.ArchivedAt) + assert.Equal(t, archived, got.ArchivedAt.Time) + }) + + t.Run("marks a remotely managed consent", func(t *testing.T) { + got := consentFromAdminRow(&sqlc.GetAdminConsentsRow{ + ID: "CN01ARZ3NDEKTSV4RRFFQ69G5FC", + Key: "members_terms", + IsRemote: true, + ManagedBy: &managedBy, + }) + + assert.Equal(t, model.ConsentManagementTypeRemote, got.ManagementType) + require.NotNil(t, got.ManagedBy) + assert.Equal(t, managedBy, *got.ManagedBy) + }) +} diff --git a/backend/internal/graph/api/generated.go b/backend/internal/graph/api/generated.go index 61ee6f51..bf62c2f5 100644 --- a/backend/internal/graph/api/generated.go +++ b/backend/internal/graph/api/generated.go @@ -246,6 +246,7 @@ type ComplexityRoot struct { } Consent struct { + ArchivedAt func(childComplexity int) int Body func(childComplexity int) int ID func(childComplexity int) int IsRequired func(childComplexity int) int @@ -614,6 +615,7 @@ type ComplexityRoot struct { AddQuizQuestion func(childComplexity int, quizID string, input model.CreateQuizQuestionInput) int AddTeamMembers func(childComplexity int, teamID string, userIds []string, force *bool) int AdminSetUserConsent func(childComplexity int, userID string, consentID string, action model.ConsentAction) int + ArchiveConsent func(childComplexity int, id string) int ArchiveProject func(childComplexity int, id string) int AssignChallengeToEvent func(childComplexity int, challengeID string, eventID string) int AssignRole func(childComplexity int, input model.AssignRoleInput) int @@ -723,6 +725,7 @@ type ComplexityRoot struct { SubmitQuizAnswer func(childComplexity int, submissionID string, input model.SubmitQuizAnswerInput) int SyncUser func(childComplexity int, userID string) int TestWebhook func(childComplexity int, id string) int + UnarchiveConsent func(childComplexity int, id string) int UncompleteChallenge func(childComplexity int, userID string, challengeID string) int UnenrollFromChallenge func(childComplexity int, challengeID string) int UnenrollUserFromChallenge func(childComplexity int, userID string, challengeID string) int @@ -990,6 +993,7 @@ type ComplexityRoot struct { Achievement func(childComplexity int, id string) int Achievements func(childComplexity int, filter model.AchievementFilter, first *int, after *string, last *int, before *string) int AdminCheckAchievementProgress func(childComplexity int, userID string, achievementID string) int + AdminConsents func(childComplexity int, projectID *string, includeArchived *bool) int AdminDashboardStats func(childComplexity int) int AdminExternalContentEvents func(childComplexity int, userID string, externalContentID string) int AdminScoreJournal func(childComplexity int, filter *model.ScoreJournalFilter, first *int, after *string, last *int, before *string) int @@ -1721,6 +1725,8 @@ type MutationResolver interface { RejectConsent(ctx context.Context, consentID string) (*model.UserConsent, error) CreateConsent(ctx context.Context, key string, title string, shortText *string, body string, url *string, publishedAt *scalars.DateTime, isRemote *bool, managedBy *string, projectID *string, isRequired *bool) (*model.Consent, error) UpdateConsent(ctx context.Context, id string, title *string, shortText *string, body *string, url *string, publishedAt *scalars.DateTime, managedBy *string, isRequired *bool) (*model.Consent, error) + ArchiveConsent(ctx context.Context, id string) (*model.Consent, error) + UnarchiveConsent(ctx context.Context, id string) (*model.Consent, error) AdminSetUserConsent(ctx context.Context, userID string, consentID string, action model.ConsentAction) (*model.UserConsentHistoryEntry, error) CreateQuiz(ctx context.Context, input model.CreateQuizInput) (*model.Quiz, error) UpdateQuiz(ctx context.Context, id string, input model.UpdateQuizInput) (*model.Quiz, error) @@ -1889,6 +1895,7 @@ type QueryResolver interface { ScoreJournal(ctx context.Context, projectID string, userID string, filter *model.ScoreJournalFilter, first *int, after *string, last *int, before *string) (*model.ScoreJournalConnection, error) AdminScoreJournal(ctx context.Context, filter *model.ScoreJournalFilter, first *int, after *string, last *int, before *string) (*model.ScoreJournalConnection, error) Consents(ctx context.Context) ([]model.Consent, error) + AdminConsents(ctx context.Context, projectID *string, includeArchived *bool) ([]model.Consent, error) Consent(ctx context.Context, id string) (*model.Consent, error) PendingConsents(ctx context.Context) ([]model.Consent, error) Quiz(ctx context.Context, id string) (*model.Quiz, error) @@ -2702,6 +2709,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Colors.Light(childComplexity), true + case "Consent.archivedAt": + if e.complexity.Consent.ArchivedAt == nil { + break + } + + return e.complexity.Consent.ArchivedAt(childComplexity), true case "Consent.body": if e.complexity.Consent.Body == nil { break @@ -4282,6 +4295,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.AdminSetUserConsent(childComplexity, args["userId"].(string), args["consentId"].(string), args["action"].(model.ConsentAction)), true + case "Mutation.archiveConsent": + if e.complexity.Mutation.ArchiveConsent == nil { + break + } + + args, err := ec.field_Mutation_archiveConsent_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ArchiveConsent(childComplexity, args["id"].(string)), true case "Mutation.archiveProject": if e.complexity.Mutation.ArchiveProject == nil { break @@ -5461,6 +5485,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.TestWebhook(childComplexity, args["id"].(string)), true + case "Mutation.unarchiveConsent": + if e.complexity.Mutation.UnarchiveConsent == nil { + break + } + + args, err := ec.field_Mutation_unarchiveConsent_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UnarchiveConsent(childComplexity, args["id"].(string)), true case "Mutation.uncompleteChallenge": if e.complexity.Mutation.UncompleteChallenge == nil { break @@ -6866,6 +6901,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Query.AdminCheckAchievementProgress(childComplexity, args["userId"].(string), args["achievementId"].(string)), true + case "Query.adminConsents": + if e.complexity.Query.AdminConsents == nil { + break + } + + args, err := ec.field_Query_adminConsents_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.AdminConsents(childComplexity, args["projectId"].(*string), args["includeArchived"].(*bool)), true case "Query.adminDashboardStats": if e.complexity.Query.AdminDashboardStats == nil { break @@ -10286,7 +10332,6 @@ type TeamMember { isTeamLead: Boolean! joinedAt: String! user: User! @goField(forceResolver: true) - "False while the member has not accepted a required consent of the project: they are listed to admins, but their points, age and membership count nowhere (unit averages, average age, member counts)." countsTowardScores: Boolean! } @@ -11474,6 +11519,7 @@ type Consent { publishedAt: DateTime managementType: ConsentManagementType! managedBy: String + archivedAt: DateTime # Must be accepted to take part in the project; cannot be rejected isRequired: Boolean! project: Project @goField(forceResolver: true) @@ -11507,9 +11553,13 @@ type ConsentStatus { # ==================== Consent Queries ==================== extend type Query { - # Get all latest published consents + # Live consents only, one version per key. Admin surfaces want adminConsents. consents: [Consent!]! + # Live consents plus drafts and scheduled versions. Admin-only, authorised + # in the resolver, not by @requireRole. ` + "`" + `projectId` + "`" + ` adds the global consents. + adminConsents(projectId: ID, includeArchived: Boolean): [Consent!]! + # Get a specific consent by ID consent(id: ID!): Consent! @@ -11552,6 +11602,12 @@ extend type Mutation { isRequired: Boolean ): Consent! @requireRole(roles: ["admin", "superadmin"]) + # Retire a consent: users stop being asked for it, and a required one stops + # blocking participation. There is no delete — the history outlives it. + archiveConsent(id: ID!): Consent! @requireRole(roles: ["admin", "superadmin"]) + + unarchiveConsent(id: ID!): Consent! @requireRole(roles: ["admin", "superadmin"]) + # Manually set a user's consent action (admin action) # Only works for local consents. The admin's user ID is recorded in the source field. adminSetUserConsent( @@ -13100,6 +13156,17 @@ func (ec *executionContext) field_Mutation_adminSetUserConsent_args(ctx context. return args, nil } +func (ec *executionContext) field_Mutation_archiveConsent_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_archiveProject_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -14595,6 +14662,17 @@ func (ec *executionContext) field_Mutation_testWebhook_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_unarchiveConsent_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_uncompleteChallenge_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -15168,6 +15246,22 @@ func (ec *executionContext) field_Query_adminCheckAchievementProgress_args(ctx c return args, nil } +func (ec *executionContext) field_Query_adminConsents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "projectId", ec.unmarshalOID2ᚖstring) + if err != nil { + return nil, err + } + args["projectId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "includeArchived", ec.unmarshalOBoolean2ᚖbool) + if err != nil { + return nil, err + } + args["includeArchived"] = arg1 + return args, nil +} + func (ec *executionContext) field_Query_adminExternalContentEvents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -19277,6 +19371,35 @@ func (ec *executionContext) fieldContext_Consent_managedBy(_ context.Context, fi return fc, nil } +func (ec *executionContext) _Consent_archivedAt(ctx context.Context, field graphql.CollectedField, obj *model.Consent) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Consent_archivedAt, + func(ctx context.Context) (any, error) { + return obj.ArchivedAt, nil + }, + nil, + ec.marshalODateTime2ᚖgithubᚗcomᚋbccᚑmediaᚋwayfarerᚋinternalᚋgraphᚋscalarsᚐDateTime, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Consent_archivedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Consent", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type DateTime does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Consent_isRequired(ctx context.Context, field graphql.CollectedField, obj *model.Consent) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -19513,6 +19636,8 @@ func (ec *executionContext) fieldContext_ConsentStatus_pendingConsents(_ context return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -34144,6 +34269,8 @@ func (ec *executionContext) fieldContext_Mutation_createConsent(ctx context.Cont return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -34233,6 +34360,8 @@ func (ec *executionContext) fieldContext_Mutation_updateConsent(ctx context.Cont return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -34259,6 +34388,188 @@ func (ec *executionContext) fieldContext_Mutation_updateConsent(ctx context.Cont return fc, nil } +func (ec *executionContext) _Mutation_archiveConsent(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_archiveConsent, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().ArchiveConsent(ctx, fc.Args["id"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin"}) + if err != nil { + var zeroVal *model.Consent + return zeroVal, err + } + if ec.directives.RequireRole == nil { + var zeroVal *model.Consent + return zeroVal, errors.New("directive requireRole is not implemented") + } + return ec.directives.RequireRole(ctx, nil, directive0, roles) + } + + next = directive1 + return next + }, + ec.marshalNConsent2ᚖgithubᚗcomᚋbccᚑmediaᚋwayfarerᚋinternalᚋgraphᚋapiᚋmodelᚐConsent, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_archiveConsent(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Consent_id(ctx, field) + case "key": + return ec.fieldContext_Consent_key(ctx, field) + case "version": + return ec.fieldContext_Consent_version(ctx, field) + case "title": + return ec.fieldContext_Consent_title(ctx, field) + case "shortText": + return ec.fieldContext_Consent_shortText(ctx, field) + case "body": + return ec.fieldContext_Consent_body(ctx, field) + case "url": + return ec.fieldContext_Consent_url(ctx, field) + case "publishedAt": + return ec.fieldContext_Consent_publishedAt(ctx, field) + case "managementType": + return ec.fieldContext_Consent_managementType(ctx, field) + case "managedBy": + return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) + case "isRequired": + return ec.fieldContext_Consent_isRequired(ctx, field) + case "project": + return ec.fieldContext_Consent_project(ctx, field) + case "userHistory": + return ec.fieldContext_Consent_userHistory(ctx, field) + case "translationStatus": + return ec.fieldContext_Consent_translationStatus(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Consent", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_archiveConsent_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_unarchiveConsent(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_unarchiveConsent, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().UnarchiveConsent(ctx, fc.Args["id"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + roles, err := ec.unmarshalNString2ᚕstringᚄ(ctx, []any{"admin", "superadmin"}) + if err != nil { + var zeroVal *model.Consent + return zeroVal, err + } + if ec.directives.RequireRole == nil { + var zeroVal *model.Consent + return zeroVal, errors.New("directive requireRole is not implemented") + } + return ec.directives.RequireRole(ctx, nil, directive0, roles) + } + + next = directive1 + return next + }, + ec.marshalNConsent2ᚖgithubᚗcomᚋbccᚑmediaᚋwayfarerᚋinternalᚋgraphᚋapiᚋmodelᚐConsent, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_unarchiveConsent(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Consent_id(ctx, field) + case "key": + return ec.fieldContext_Consent_key(ctx, field) + case "version": + return ec.fieldContext_Consent_version(ctx, field) + case "title": + return ec.fieldContext_Consent_title(ctx, field) + case "shortText": + return ec.fieldContext_Consent_shortText(ctx, field) + case "body": + return ec.fieldContext_Consent_body(ctx, field) + case "url": + return ec.fieldContext_Consent_url(ctx, field) + case "publishedAt": + return ec.fieldContext_Consent_publishedAt(ctx, field) + case "managementType": + return ec.fieldContext_Consent_managementType(ctx, field) + case "managedBy": + return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) + case "isRequired": + return ec.fieldContext_Consent_isRequired(ctx, field) + case "project": + return ec.fieldContext_Consent_project(ctx, field) + case "userHistory": + return ec.fieldContext_Consent_userHistory(ctx, field) + case "translationStatus": + return ec.fieldContext_Consent_translationStatus(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Consent", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_unarchiveConsent_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_adminSetUserConsent(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -45551,6 +45862,70 @@ func (ec *executionContext) fieldContext_Query_consents(_ context.Context, field return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) + case "isRequired": + return ec.fieldContext_Consent_isRequired(ctx, field) + case "project": + return ec.fieldContext_Consent_project(ctx, field) + case "userHistory": + return ec.fieldContext_Consent_userHistory(ctx, field) + case "translationStatus": + return ec.fieldContext_Consent_translationStatus(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Consent", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_adminConsents(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_adminConsents, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Query().AdminConsents(ctx, fc.Args["projectId"].(*string), fc.Args["includeArchived"].(*bool)) + }, + nil, + ec.marshalNConsent2ᚕgithubᚗcomᚋbccᚑmediaᚋwayfarerᚋinternalᚋgraphᚋapiᚋmodelᚐConsentᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_adminConsents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Consent_id(ctx, field) + case "key": + return ec.fieldContext_Consent_key(ctx, field) + case "version": + return ec.fieldContext_Consent_version(ctx, field) + case "title": + return ec.fieldContext_Consent_title(ctx, field) + case "shortText": + return ec.fieldContext_Consent_shortText(ctx, field) + case "body": + return ec.fieldContext_Consent_body(ctx, field) + case "url": + return ec.fieldContext_Consent_url(ctx, field) + case "publishedAt": + return ec.fieldContext_Consent_publishedAt(ctx, field) + case "managementType": + return ec.fieldContext_Consent_managementType(ctx, field) + case "managedBy": + return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -45563,6 +45938,17 @@ func (ec *executionContext) fieldContext_Query_consents(_ context.Context, field return nil, fmt.Errorf("no field named %q was found under type Consent", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_adminConsents_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -45611,6 +45997,8 @@ func (ec *executionContext) fieldContext_Query_consent(ctx context.Context, fiel return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -45681,6 +46069,8 @@ func (ec *executionContext) fieldContext_Query_pendingConsents(_ context.Context return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -58121,6 +58511,8 @@ func (ec *executionContext) fieldContext_UserConsent_consent(_ context.Context, return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -58267,6 +58659,8 @@ func (ec *executionContext) fieldContext_UserConsentHistoryEntry_consent(_ conte return ec.fieldContext_Consent_managementType(ctx, field) case "managedBy": return ec.fieldContext_Consent_managedBy(ctx, field) + case "archivedAt": + return ec.fieldContext_Consent_archivedAt(ctx, field) case "isRequired": return ec.fieldContext_Consent_isRequired(ctx, field) case "project": @@ -68035,6 +68429,8 @@ func (ec *executionContext) _Consent(ctx context.Context, sel ast.SelectionSet, } case "managedBy": out.Values[i] = ec._Consent_managedBy(ctx, field, obj) + case "archivedAt": + out.Values[i] = ec._Consent_archivedAt(ctx, field, obj) case "isRequired": out.Values[i] = ec._Consent_isRequired(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -72631,6 +73027,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "archiveConsent": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_archiveConsent(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "unarchiveConsent": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_unarchiveConsent(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "adminSetUserConsent": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_adminSetUserConsent(ctx, field) @@ -76673,6 +77083,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "adminConsents": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_adminConsents(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "consent": field := field diff --git a/backend/internal/graph/api/model/models_gen.go b/backend/internal/graph/api/model/models_gen.go index 940931da..646bd55f 100644 --- a/backend/internal/graph/api/model/models_gen.go +++ b/backend/internal/graph/api/model/models_gen.go @@ -358,6 +358,7 @@ type Consent struct { PublishedAt *scalars.DateTime `json:"publishedAt,omitempty"` ManagementType ConsentManagementType `json:"managementType"` ManagedBy *string `json:"managedBy,omitempty"` + ArchivedAt *scalars.DateTime `json:"archivedAt,omitempty"` IsRequired bool `json:"isRequired"` Project *Project `json:"project,omitempty"` UserHistory []UserConsentHistoryEntry `json:"userHistory"` @@ -2342,16 +2343,15 @@ type TeamFilter struct { } type TeamMember struct { - ID string `json:"id"` - Name string `json:"name"` - Church *Church `json:"church"` - IsTeamLead bool `json:"isTeamLead"` - JoinedAt string `json:"joinedAt"` - User *User `json:"user"` - // False while the member has not accepted a required consent of the project: they are listed to admins, but their points, age and membership count nowhere (unit averages, average age, member counts). - CountsTowardScores bool `json:"countsTowardScores"` - ChurchID string `json:"-"` - UserID string `json:"-"` + ID string `json:"id"` + Name string `json:"name"` + Church *Church `json:"church"` + IsTeamLead bool `json:"isTeamLead"` + JoinedAt string `json:"joinedAt"` + User *User `json:"user"` + CountsTowardScores bool `json:"countsTowardScores"` + ChurchID string `json:"-"` + UserID string `json:"-"` } type TranslationFieldStatus struct { diff --git a/backend/internal/loaders/consent.go b/backend/internal/loaders/consent.go index 67cf865a..becee37c 100644 --- a/backend/internal/loaders/consent.go +++ b/backend/internal/loaders/consent.go @@ -49,6 +49,12 @@ func consentByIDBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func(cont publishedAt = &dt } + var archivedAt *scalars.DateTime + if row.ArchivedAt.Valid { + dt := scalars.DateTime{Time: row.ArchivedAt.Time} + archivedAt = &dt + } + managementType := model.ConsentManagementTypeLocal if row.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -63,6 +69,7 @@ func consentByIDBatchFunc(db *database.DB, c *cache.CacheWithRegistry) func(cont BodyMarkdown: row.Body, URL: row.Url, PublishedAt: publishedAt, + ArchivedAt: archivedAt, ManagementType: managementType, ManagedBy: row.ManagedBy, ProjectID: row.ProjectID, diff --git a/backend/internal/loaders/user.go b/backend/internal/loaders/user.go index a69eba41..2b81a9de 100644 --- a/backend/internal/loaders/user.go +++ b/backend/internal/loaders/user.go @@ -150,6 +150,12 @@ func fetchConsentDataForUsers(ctx context.Context, db *database.DB, c *cache.Cac publishedAt = &dt } + var archivedAt *scalars.DateTime + if row.ArchivedAt.Valid { + dt := scalars.DateTime{Time: row.ArchivedAt.Time} + archivedAt = &dt + } + managementType := model.ConsentManagementTypeLocal if row.IsRemote { managementType = model.ConsentManagementTypeRemote @@ -164,6 +170,7 @@ func fetchConsentDataForUsers(ctx context.Context, db *database.DB, c *cache.Cac BodyMarkdown: row.Body, URL: row.Url, PublishedAt: publishedAt, + ArchivedAt: archivedAt, ManagementType: managementType, ManagedBy: row.ManagedBy, ProjectID: row.ProjectID, diff --git a/frontend/app/api/generated.ts b/frontend/app/api/generated.ts index 3dfa2cc9..c346b27d 100644 --- a/frontend/app/api/generated.ts +++ b/frontend/app/api/generated.ts @@ -357,6 +357,7 @@ export type ColorsInput = { export type Consent = { __typename?: 'Consent'; + archivedAt?: Maybe; body: MarkdownText; id: Scalars['ID']['output']; isRequired: Scalars['Boolean']['output']; @@ -1202,6 +1203,7 @@ export type Mutation = { addQuizQuestion: QuizQuestion; addTeamMembers: Team; adminSetUserConsent: UserConsentHistoryEntry; + archiveConsent: Consent; archiveProject: Scalars['Boolean']['output']; assignChallengeToEvent: Challenge; assignRole: UserRole; @@ -1316,6 +1318,7 @@ export type Mutation = { submitQuizAnswer: QuizResponse; syncUser: SyncUserResult; testWebhook: WebhookLog; + unarchiveConsent: Consent; uncompleteChallenge: Scalars['Boolean']['output']; unenrollFromChallenge: Scalars['Boolean']['output']; unenrollUserFromChallenge: Scalars['Boolean']['output']; @@ -1370,6 +1373,11 @@ export type MutationAdminSetUserConsentArgs = { }; +export type MutationArchiveConsentArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationArchiveProjectArgs = { id: Scalars['ID']['input']; }; @@ -1963,6 +1971,11 @@ export type MutationTestWebhookArgs = { }; +export type MutationUnarchiveConsentArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationUncompleteChallengeArgs = { challengeId: Scalars['ID']['input']; userId: Scalars['ID']['input']; @@ -2442,6 +2455,7 @@ export type Query = { achievement: Achievement; achievements: AchievementConnection; adminCheckAchievementProgress: AdminAchievementProgress; + adminConsents: Array; adminDashboardStats: AdminDashboardStats; adminExternalContentEvents: Array; adminScoreJournal: ScoreJournalConnection; @@ -2529,6 +2543,12 @@ export type QueryAdminCheckAchievementProgressArgs = { }; +export type QueryAdminConsentsArgs = { + includeArchived?: InputMaybe; + projectId?: InputMaybe; +}; + + export type QueryAdminExternalContentEventsArgs = { externalContentId: Scalars['ID']['input']; userId: Scalars['ID']['input']; @@ -3415,7 +3435,6 @@ export type TeamFilter = { export type TeamMember = { __typename?: 'TeamMember'; church: Church; - /** False while the member has not accepted a required consent of the project: they are listed to admins, but their points, age and membership count nowhere (unit averages, average age, member counts). */ countsTowardScores: Scalars['Boolean']['output']; id: Scalars['ID']['output']; isTeamLead: Scalars['Boolean']['output']; @@ -4083,6 +4102,20 @@ export type UpdateConsentMutationVariables = Exact<{ export type UpdateConsentMutation = { __typename?: 'Mutation', updateConsent: { __typename?: 'Consent', id: string, key: string, version: number, title: string } }; +export type ArchiveConsentMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type ArchiveConsentMutation = { __typename?: 'Mutation', archiveConsent: { __typename?: 'Consent', id: string, archivedAt?: any | null } }; + +export type UnarchiveConsentMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type UnarchiveConsentMutation = { __typename?: 'Mutation', unarchiveConsent: { __typename?: 'Consent', id: string, archivedAt?: any | null } }; + export type DeleteEventMutationVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -4808,12 +4841,14 @@ export type AdminConsentPageQueryVariables = Exact<{ }>; -export type AdminConsentPageQuery = { __typename?: 'Query', consent: { __typename?: 'Consent', id: string, key: string, version: number, title: string, shortText: string, url?: string | null, publishedAt?: any | null, managementType: ConsentManagementType, managedBy?: string | null, isRequired: boolean, body: { __typename?: 'MarkdownText', markdown: string, html: string }, project?: { __typename?: 'Project', id: string, name: string } | null, translationStatus: Array<{ __typename?: 'TranslationFieldStatus', languageCode: string, fields: Array }> } }; +export type AdminConsentPageQuery = { __typename?: 'Query', consent: { __typename?: 'Consent', id: string, key: string, version: number, title: string, shortText: string, url?: string | null, publishedAt?: any | null, archivedAt?: any | null, managementType: ConsentManagementType, managedBy?: string | null, isRequired: boolean, body: { __typename?: 'MarkdownText', markdown: string, html: string }, project?: { __typename?: 'Project', id: string, name: string } | null, translationStatus: Array<{ __typename?: 'TranslationFieldStatus', languageCode: string, fields: Array }> } }; -export type AdminConsentsPageQueryVariables = Exact<{ [key: string]: never; }>; +export type AdminConsentsPageQueryVariables = Exact<{ + includeArchived?: InputMaybe; +}>; -export type AdminConsentsPageQuery = { __typename?: 'Query', consents: Array<{ __typename?: 'Consent', id: string, key: string, version: number, title: string, shortText: string, publishedAt?: any | null, managementType: ConsentManagementType, managedBy?: string | null }> }; +export type AdminConsentsPageQuery = { __typename?: 'Query', adminConsents: Array<{ __typename?: 'Consent', id: string, key: string, version: number, title: string, publishedAt?: any | null, archivedAt?: any | null, managementType: ConsentManagementType, isRequired: boolean, project?: { __typename?: 'Project', id: string, name: string } | null }> }; export type AdminNewConsentProjectsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5114,6 +5149,14 @@ export type AdminProjectChallengeNewPageQueryVariables = Exact<{ export type AdminProjectChallengeNewPageQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, name: string, branding: { __typename?: 'Branding', colors: { __typename?: 'Colors', light: { __typename?: 'ColorSet', accent: string, accentContrast: string, onAccent: string, backgroundDefault: string, backgroundRaised: string, backgroundIndent: string, textDefault: string, textMuted: string, textHint: string, shadowDefault: string, shadowBlank: string, borderDefault: string }, dark: { __typename?: 'ColorSet', accent: string, accentContrast: string, onAccent: string, backgroundDefault: string, backgroundRaised: string, backgroundIndent: string, textDefault: string, textMuted: string, textHint: string, shadowDefault: string, shadowBlank: string, borderDefault: string } } } } }; +export type AdminProjectConsentsPageQueryVariables = Exact<{ + projectId: Scalars['ID']['input']; + includeArchived?: InputMaybe; +}>; + + +export type AdminProjectConsentsPageQuery = { __typename?: 'Query', adminConsents: Array<{ __typename?: 'Consent', id: string, key: string, version: number, title: string, publishedAt?: any | null, archivedAt?: any | null, managementType: ConsentManagementType, isRequired: boolean, project?: { __typename?: 'Project', id: string, name: string } | null }> }; + export type AdminProjectEditPageQueryVariables = Exact<{ projectId: Scalars['ID']['input']; }>; @@ -5974,6 +6017,30 @@ export const UpdateConsentDocument = gql` export function useUpdateConsentMutation() { return Urql.useMutation(UpdateConsentDocument); }; +export const ArchiveConsentDocument = gql` + mutation ArchiveConsent($id: ID!) { + archiveConsent(id: $id) { + id + archivedAt + } +} + `; + +export function useArchiveConsentMutation() { + return Urql.useMutation(ArchiveConsentDocument); +}; +export const UnarchiveConsentDocument = gql` + mutation UnarchiveConsent($id: ID!) { + unarchiveConsent(id: $id) { + id + archivedAt + } +} + `; + +export function useUnarchiveConsentMutation() { + return Urql.useMutation(UnarchiveConsentDocument); +}; export const DeleteEventDocument = gql` mutation DeleteEvent($id: ID!) { deleteEvent(id: $id) @@ -7479,6 +7546,7 @@ export const AdminConsentPageDocument = gql` } url publishedAt + archivedAt managementType managedBy isRequired @@ -7497,16 +7565,20 @@ export function useAdminConsentPageQuery(options?: Omit({ query: AdminConsentPageDocument, variables: undefined, ...options }); }; export const AdminConsentsPageDocument = gql` - query AdminConsentsPage { - consents { + query AdminConsentsPage($includeArchived: Boolean) { + adminConsents(includeArchived: $includeArchived) { id key version title - shortText publishedAt + archivedAt managementType - managedBy + isRequired + project { + id + name + } } } `; @@ -8349,6 +8421,28 @@ export const AdminProjectChallengeNewPageDocument = gql` export function useAdminProjectChallengeNewPageQuery(options?: Omit, 'query'>) { return Urql.useQuery({ query: AdminProjectChallengeNewPageDocument, variables: undefined, ...options }); }; +export const AdminProjectConsentsPageDocument = gql` + query AdminProjectConsentsPage($projectId: ID!, $includeArchived: Boolean) { + adminConsents(projectId: $projectId, includeArchived: $includeArchived) { + id + key + version + title + publishedAt + archivedAt + managementType + isRequired + project { + id + name + } + } +} + `; + +export function useAdminProjectConsentsPageQuery(options?: Omit, 'query'>) { + return Urql.useQuery({ query: AdminProjectConsentsPageDocument, variables: undefined, ...options }); +}; export const AdminProjectEditPageDocument = gql` query AdminProjectEditPage($projectId: ID!) { project(id: $projectId) { diff --git a/frontend/app/graphql/mutations/consents.gql b/frontend/app/graphql/mutations/consents.gql index 352e51d5..636c400f 100644 --- a/frontend/app/graphql/mutations/consents.gql +++ b/frontend/app/graphql/mutations/consents.gql @@ -71,3 +71,17 @@ mutation UpdateConsent( title } } + +mutation ArchiveConsent($id: ID!) { + archiveConsent(id: $id) { + id + archivedAt + } +} + +mutation UnarchiveConsent($id: ID!) { + unarchiveConsent(id: $id) { + id + archivedAt + } +} diff --git a/frontend/layers/admin/app/components/admin/consent/AdminConsentSections.vue b/frontend/layers/admin/app/components/admin/consent/AdminConsentSections.vue new file mode 100644 index 00000000..145cdee4 --- /dev/null +++ b/frontend/layers/admin/app/components/admin/consent/AdminConsentSections.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/layers/admin/app/components/admin/consent/AdminConsentStatusBadge.vue b/frontend/layers/admin/app/components/admin/consent/AdminConsentStatusBadge.vue new file mode 100644 index 00000000..12e823a4 --- /dev/null +++ b/frontend/layers/admin/app/components/admin/consent/AdminConsentStatusBadge.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/layers/admin/app/components/admin/consent/AdminConsentTable.vue b/frontend/layers/admin/app/components/admin/consent/AdminConsentTable.vue new file mode 100644 index 00000000..5a5f1359 --- /dev/null +++ b/frontend/layers/admin/app/components/admin/consent/AdminConsentTable.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/layers/admin/app/pages/admin/consents/[consentId].vue b/frontend/layers/admin/app/pages/admin/consents/[consentId].vue index 51e4afab..0bb4c114 100644 --- a/frontend/layers/admin/app/pages/admin/consents/[consentId].vue +++ b/frontend/layers/admin/app/pages/admin/consents/[consentId].vue @@ -18,6 +18,7 @@ gql(` } url publishedAt + archivedAt managementType managedBy isRequired @@ -51,13 +52,14 @@ const { // above it is derived from the route. useAdminPage(() => data.value?.consent.title) -const managementTypeLabels: Record = { - LOCAL: 'Lokal', - REMOTE: 'Ekstern', -} - const { executeMutation: updateConsent } = useUpdateConsentMutation() +const { executeMutation: archiveConsent } = useArchiveConsentMutation() +const { executeMutation: unarchiveConsent } = useUnarchiveConsentMutation() +const { confirm } = useConfirm() const toast = useToast() +const { canManageConsents } = usePermissions() + +const isArchived = computed(() => !!data.value?.consent.archivedAt) // Edit mode state const isEditing = ref(false) @@ -140,10 +142,51 @@ async function publishConsent() { refetch({ requestPolicy: 'network-only' }) } + +async function handleArchive() { + if (!data.value) return + + const confirmed = await confirm({ + title: `Arkivere "${data.value.consent.title}"?`, + description: CONSENT_ARCHIVE_DESCRIPTION, + confirmLabel: 'Arkiver', + icon: 'lucide:archive', + color: 'primary', + }) + if (!confirmed) return + + const result = await archiveConsent({ id: route.params.consentId }) + + if (result.error) { + toast.add({ + title: 'Kunne ikke arkivere samtykke', + description: result.error.message, + color: 'error', + }) + return + } + + toast.add({ title: 'Samtykke arkivert', color: 'success' }) + refetch({ requestPolicy: 'network-only' }) +} + +async function handleUnarchive() { + const result = await unarchiveConsent({ id: route.params.consentId }) + + if (result.error) { + toast.add({ + title: 'Kunne ikke gjenopprette samtykke', + description: result.error.message, + color: 'error', + }) + return + } + + toast.add({ title: 'Samtykke gjenopprettet', color: 'success' }) + refetch({ requestPolicy: 'network-only' }) +} -