feat: Community translation v1.1 — Contributors (pitch #3806)#3819
feat: Community translation v1.1 — Contributors (pitch #3806)#3819Anty0 wants to merge 6 commits into
Conversation
📝 WalkthroughWalkthroughAdds database-backed project contributor tracking, a secured paginated contributor API, public-project contribution filtering, contribution state in initial data, community navigation controls, contributor invitations, Team/Community member tabs, and username disclosure changes. ChangesProject contributors and community contributions
Estimated code review effort: 5 (Critical) | ~100 minutes Possibly related issues
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.kt`:
- Around line 28-30: Make contributor-cache cleanup project-scoped: in
NullTypedActivityRevisionStorageTest.cleanup, explicitly delete
project_contributor rows for this test project or update the cleanup service
accordingly; in ProjectContributorBackfillTest, replace the global delete with a
project_id-filtered delete using that test’s project. Do not retain any unscoped
contributor-row deletion.
In `@backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt`:
- Around line 203-204: Update the ActivityService persistence skip logic around
saveWithoutModification so a null-typed revision is not skipped when
activityRevision.describingRelations is non-empty. Keep relation persistence
through persistDescribingRelations independent from modified-entity persistence,
and add a regression test covering a relation-only null-typed revision.
In
`@backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt`:
- Around line 10-20: Update ContributorsTestData to extend BaseTestData and
remove its manual TestDataBuilder ownership, while preserving the existing
fixture fields and setup behavior through the inherited base-test-data contract.
In `@backend/data/src/main/resources/db/changelog/schema.xml`:
- Around line 5835-5840: The rollback in changeSet 1784467200000-3 is
destructive because it deletes all project_contributor rows. Replace the DELETE
rollback with a no-op or a reversible backfill-specific restoration that
preserves trigger-created and pre-existing cache data; keep the forward
projectContributorBackfill.sql operation unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 76b9713a-fa6b-4d5b-90b8-a7def5c9ba1d
⛔ Files ignored due to path filters (2)
webapp/src/service/apiSchema.generated.tsis excluded by!**/*.generated.*webapp/src/service/apiSchemaTypes.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (17)
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectContributorsController.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModelAssembler.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorBackfillTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorsControllerTest.ktbackend/data/src/main/kotlin/io/tolgee/activity/ActivityService.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributor.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributorId.ktbackend/data/src/main/kotlin/io/tolgee/model/views/ProjectContributorView.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectContributorRepository.ktbackend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.ktbackend/data/src/main/kotlin/io/tolgee/service/projectExportImport/ProjectExportImportPolicyRegistry.ktbackend/data/src/main/resources/db/changelog/projectContributorBackfill.sqlbackend/data/src/main/resources/db/changelog/projectContributorTrigger.sqlbackend/data/src/main/resources/db/changelog/schema.xml
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts (1)
20-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePlace the suite before this helper.
This low-level helper appears before the top-level
describeblocks. Move it below the suites or into shared test helpers so the file follows the Stepdown Rule.As per path instructions, “The most important, high-level concepts should be at the top” and callers should appear before functions they call.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts` around lines 20 - 23, Move the low-level disableMyContributions helper below the top-level describe suites in the test file, or relocate it to shared test helpers, so the suite declarations and other high-level concepts appear before the helper they call.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt`:
- Around line 255-269: Move the activity fixture setup out of recordActivity and
into the relevant TestData class. Create ActivityRevision instances through that
TestData graph, persist them with testDataService, and clean them in the suite’s
`@AfterEach` using the existing TestData lifecycle pattern; remove the direct
entityManager.persist/flush setup from recordActivity while preserving its
projectId, authorId, and timestamp behavior.
In
`@backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.kt`:
- Around line 31-35: Replace the empty ActivityRevision persisted by the
controller with a typed revision containing a valid modification for
publicProject in
backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.kt
lines 31-35, and apply the same valid activity shape for outsiderProject in
backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt
lines 33-37. Ensure both persisted revisions define a type and modifications,
avoiding null-typed records.
In `@e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts`:
- Around line 300-301: Replace the cy.contains() assertions in the community
project navigation test with gcy()/cy.gcy() selectors. Add a stable static
data-cy value to each project row and expose the project identity through a
separate data-cy-item attribute, then assert visibility and nonexistence using
those attributes instead of rendered text.
---
Nitpick comments:
In `@e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts`:
- Around line 20-23: Move the low-level disableMyContributions helper below the
top-level describe suites in the test file, or relocate it to shared test
helpers, so the suite declarations and other high-level concepts appear before
the helper they call.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e4ecdb91-7a6b-42cb-ad80-cbcfddb03b89
⛔ Files ignored due to path filters (1)
webapp/src/service/apiSchema.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (21)
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InitialDataControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/CommunityContributionE2eData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectContributorRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.ktbackend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.ktbackend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kte2e/cypress/common/apiCalls/testData/testData.tse2e/cypress/e2e/projects/communityProjectsNavigation.cy.tse2e/cypress/support/dataCyType.d.tswebapp/src/component/organizationSwitch/OrganizationSwitch.tsxwebapp/src/globalContext/helpers.tsxwebapp/src/views/projects/CommunityProjectsView.tsxwebapp/src/views/projects/MyContributionsToggle.tsxwebapp/src/views/projects/usePublicProjectsList.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- backend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.kt
- backend/data/src/main/kotlin/io/tolgee/repository/ProjectContributorRepository.kt
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
webapp/src/views/projects/members/ProjectMembersView.tsx (1)
55-97: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider gating team vs. community queries by the active tab.
membersLoadableandinvitationsLoadablehave noenabledcondition, so they keep fetching even whileshowCommunityis true and the Team section is only CSS-hidden. Gating them (and vice versa forcontributorsLoadable, which already hasenabled: project.public) avoids redundant network calls whenever a user is on the Community tab.♻️ Proposed refactor
const membersLoadable = useApiQuery({ url: '/v2/projects/{projectId}/users', method: 'get', path: { projectId: project.id }, query: { page, sort: ['name'], search, filterAgency: filterAgency?.map((a) => Number(a)), }, options: { + enabled: !showCommunity, keepPreviousData: true, }, }); const invitationsLoadable = useApiQuery({ url: '/v2/projects/{projectId}/invitations', method: 'get', path: { projectId: project.id }, options: { + enabled: !showCommunity, keepPreviousData: true, }, });Note:
showCommunityis computed after these two loadables, so this would need reordering or ashowCommunity-independent flag (e.g. derive fromactiveTabdirectly) to avoid a forward reference.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/ProjectMembersView.tsx` around lines 55 - 97, Gate the team queries in membersLoadable and invitationsLoadable so they are enabled only when the Team tab is active, while retaining any required project conditions. Update contributorsLoadable to enable only for public projects on the Community tab, using an activeTab-derived condition or reordering the showCommunity computation to avoid a forward reference.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@e2e/cypress/e2e/projects/communityMembersTab.cy.ts`:
- Around line 53-54: Update the contributor absence assertions in the community
members test to scope them through the contributor list’s data-cy selector and
assert that it does not contain either email. Use the project’s typed
gcy()/cy.gcy() helper and avoid raw cy.contains() text searches.
---
Nitpick comments:
In `@webapp/src/views/projects/members/ProjectMembersView.tsx`:
- Around line 55-97: Gate the team queries in membersLoadable and
invitationsLoadable so they are enabled only when the Team tab is active, while
retaining any required project conditions. Update contributorsLoadable to enable
only for public projects on the Community tab, using an activeTab-derived
condition or reordering the showCommunity computation to avoid a forward
reference.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0c6ef4ec-3636-4c92-a5f1-0449f61b568b
📒 Files selected for processing (9)
backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.ktbackend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kte2e/cypress/common/apiCalls/testData/testData.tse2e/cypress/e2e/projects/communityMembersTab.cy.tse2e/cypress/support/dataCyType.d.tswebapp/src/views/projects/members/ProjectMembersView.tsxwebapp/src/views/projects/members/component/ContributorItem.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- e2e/cypress/common/apiCalls/testData/testData.ts
34b9f46 to
70a1fe0
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt (1)
54-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRestore top-down method ordering.
Move
generateaboveafterTestDataStoredandrecordContributorActivity; its callback currently appears before its caller. As per path instructions, “Functions should be ordered so that a caller appears before the functions it calls.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt` around lines 54 - 76, Restore top-down ordering in AbstractE2eDataController by moving the generate method above afterTestDataStored and recordContributorActivity. Keep the method implementations unchanged; only reorder them so callers appear before the functions they invoke.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorBackfillTest.kt`:
- Line 44: Replace each table-wide cleanup in the three backfill tests with a
DELETE scoped to the fixture project, using the project identifier established
by each test. Update all occurrences in the test methods around the existing
backfill invocation, preserving cleanup of only that test’s project
contributors.
In
`@backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kt`:
- Around line 10-20: Remove the singleton-scoped currentTestData dependency in
MembersCommunityE2eDataController by passing the ContributorsTestData generated
in testData through the afterTestDataStored callback, or otherwise storing it in
request-local state. Ensure each callback uses the data from its own request
when recording contributor activity, without sharing mutable fixture state
across requests.
---
Nitpick comments:
In
`@backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt`:
- Around line 54-76: Restore top-down ordering in AbstractE2eDataController by
moving the generate method above afterTestDataStored and
recordContributorActivity. Keep the method implementations unchanged; only
reorder them so callers appear before the functions they invoke.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 51a38430-47f7-47b2-973a-025f7f434e89
⛔ Files ignored due to path filters (2)
webapp/src/service/apiSchema.generated.tsis excluded by!**/*.generated.*webapp/src/service/apiSchemaTypes.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (48)
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectContributorsController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InitialDataControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorBackfillTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorsControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/contributors/ContributorActivityRecorder.ktbackend/app/src/test/kotlin/io/tolgee/service/UserAccountServiceTest.ktbackend/app/src/test/kotlin/io/tolgee/service/project/ProjectHardDeletingServiceTest.ktbackend/data/src/main/kotlin/io/tolgee/activity/ActivityService.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/CommunityContributionE2eData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributor.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributorId.ktbackend/data/src/main/kotlin/io/tolgee/model/views/ProjectContributorView.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectContributorRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.ktbackend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.ktbackend/data/src/main/kotlin/io/tolgee/service/project/ProjectHardDeletingService.ktbackend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.ktbackend/data/src/main/kotlin/io/tolgee/service/projectExportImport/ProjectExportImportPolicyRegistry.ktbackend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.ktbackend/data/src/main/resources/db/changelog/projectContributorBackfill.sqlbackend/data/src/main/resources/db/changelog/projectContributorTrigger.sqlbackend/data/src/main/resources/db/changelog/schema.xmlbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kte2e/cypress/common/apiCalls/testData/testData.tse2e/cypress/e2e/projects/communityMembersTab.cy.tse2e/cypress/e2e/projects/communityProjectsNavigation.cy.tse2e/cypress/support/dataCyType.d.tswebapp/src/component/organizationSwitch/OrganizationSwitch.tsxwebapp/src/globalContext/helpers.tsxwebapp/src/views/projects/CommunityProjectsView.tsxwebapp/src/views/projects/MyContributionsToggle.tsxwebapp/src/views/projects/members/ProjectMembersView.tsxwebapp/src/views/projects/members/component/ContributorItem.tsxwebapp/src/views/projects/usePublicProjectsList.ts
🚧 Files skipped from review as they are similar to previous changes (29)
- backend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModel.kt
- backend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributor.kt
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModelAssembler.kt
- backend/data/src/main/kotlin/io/tolgee/model/views/ProjectContributorView.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt
- backend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributorId.kt
- e2e/cypress/support/dataCyType.d.ts
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/CommunityContributionE2eData.kt
- backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt
- backend/data/src/main/kotlin/io/tolgee/service/projectExportImport/ProjectExportImportPolicyRegistry.kt
- backend/data/src/main/resources/db/changelog/projectContributorBackfill.sql
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InitialDataControllerTest.kt
- webapp/src/views/projects/usePublicProjectsList.ts
- e2e/cypress/common/apiCalls/testData/testData.ts
- webapp/src/views/projects/CommunityProjectsView.tsx
- backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.kt
- backend/data/src/main/resources/db/changelog/projectContributorTrigger.sql
- backend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.kt
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.kt
- webapp/src/views/projects/members/component/ContributorItem.tsx
- backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt
- webapp/src/component/organizationSwitch/OrganizationSwitch.tsx
- webapp/src/views/projects/members/ProjectMembersView.tsx
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt
- backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt
- backend/data/src/main/resources/db/changelog/schema.xml
- e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts
- e2e/cypress/e2e/projects/communityMembersTab.cy.ts
70a1fe0 to
37ab36d
Compare
Part of pitch #3806 (Community translation v1.1 — Contributors), **scope D**. Targets the integration branch (PR #3819), not `main`. ## What Adds an **"Invite as member"** button to a Community-tab row on the project Members page. Clicking it invites that contributor to the project **by user id** — the server resolves their address, sends the invitation mail, and never returns it to the org. - New endpoint `PUT /v2/projects/{projectId}/invite-contributor` (`MEMBERS_EDIT`); the body carries `userId` + permission fields, **no email**. - **Email-free by construction.** The address is stored server-side (needed to send the mail and bind acceptance) but is masked at read time by `Invitation.visibleEmail` — the single home of the rule — which **every** assembler that serializes an invitee address reads (Team-tab pending list *and* the unauthenticated `invitation_info` endpoint). Pitch hard-constraint 1: no contributor email reaches the org before acceptance. - **Anti-oracle guard.** Only *visible contributors* of the project may be invited (reusing the Community tab's member / deleted / disabled exclusions, via a single shared `VISIBLE_CONTRIBUTOR` query). Every other id — nonexistent, deleted, disabled, member, org member, or a contributor of another project — returns one **byte-identical 404**, so the endpoint leaks neither account existence nor state. - **No permission-less membership.** `type = NONE` is rejected server-side (`CANNOT_INVITE_CONTRIBUTOR_WITH_NO_PERMISSION`), not merely hidden in the picker. - Scoped/advanced invites route through the EE service exactly like the email invite; `agencyId` stays null (a non-null agency would short-circuit acceptance binding). - Acceptance is bound to the invitee (`email = user.username`); a different user gets `INVITATION_EMAIL_MISMATCH`. ## Frontend - `members.edit`-gated **"Invite as member"** button on the contributor row, opening the existing `PermissionsModal` picker with `hideNone` (matching the email dialog). The permission→body mapping is factored into a shared helper used by both invite hooks. ## Tests - Backend (`InviteContributorControllerTest`, `InviteContributorEeTest`): email stored + hidden, model + public-endpoint email null, `MEMBERS_EDIT` gate, uniform-404 for every non-visible-contributor id, re-invite dedup, NONE rejection, EE scoped routing + granted languages, nameless-contributor path, acceptance binding + granted membership. - Frontend: vitest over the shared permission-body helper (all role/scope/language branches, incl. scope-based filtering); an E2E case that invites a contributor from the Community tab (typed `data-cy` selectors), asserts the request succeeds, that a pending invitation appears, and that the `NONE` role is absent from the picker. ## Notes - Accepted, documented limitations (see the plan's Risks): an already-invited contributor stays listed with a live button until they accept (a second click fails safely with a no-email error), and acceptance is email-bound rather than user-FK-bound (a full user-FK model is deferred to a follow-up across all invitation kinds).
A revision with no activity type and no modified entities carries no information and renders as an empty UNKNOWN row. The store guard only dropped such empties when the revision was typed; a null-typed revision fell through `shouldSaveWithoutModification()` (which returned true for a null type) and was persisted. Treat a null type like a default type so these no-op revisions are not stored.
…oint
Adds per-project contributor tracking derived from the activity log: a
`project_contributor` cache table maintained by an AFTER-INSERT trigger on
`activity_revision` (mirroring the log — no foreign keys, backfilled once for
history), plus a paged, MEMBERS_VIEW-gated, email-free
`GET /v2/projects/{projectId}/contributors` endpoint that lists non-member
contributors and excludes current members, deleted and disabled users.
Part of pitch #3806 (Community translation v1.1 — Contributors), scope A.
…entry (#3820) Add the "My contributions only" toggle to /community-projects (default on; lists public projects the user contributed to as a non-member, off lists all public projects) via an opt-in filterContributed param on GET /v2/public/projects/with-stats. Re-wire the "Community translation" org-switcher entry, gated on a new InitialDataModel.hasCommunityContributions flag; the gate and the list filter share one NON_MEMBER_CONTRIBUTOR_FILTER constant. Re-enable the five skipped switcher-navigation e2e tests and add default-on/negative-branch coverage.
Adds a Community tab to the project Members page listing non-member contributors
(avatar, name, first/last contribution date), email-free and gated behind
MEMBERS_VIEW; the tab bar appears only when the project is public and has >=1
contributor. Consumes the existing GET /v2/projects/{projectId}/contributors
endpoint. Includes a Cypress e2e spec + e2e data controller, and fixes
cleanTestData to tear down deleted/disabled fixture users.
Part of pitch #3806 (Community translation v1.1 — Contributors), scope B.
Part of pitch #3806 (Community translation v1.1 — Contributors), **scope D**. Targets the integration branch (PR #3819), not `main`. ## What Adds an **"Invite as member"** button to a Community-tab row on the project Members page. Clicking it invites that contributor to the project **by user id** — the server resolves their address, sends the invitation mail, and never returns it to the org. - New endpoint `PUT /v2/projects/{projectId}/invite-contributor` (`MEMBERS_EDIT`); the body carries `userId` + permission fields, **no email**. - **Email-free by construction.** The address is stored server-side (needed to send the mail and bind acceptance) but is masked at read time by `Invitation.visibleEmail` — the single home of the rule — which **every** assembler that serializes an invitee address reads (Team-tab pending list *and* the unauthenticated `invitation_info` endpoint). Pitch hard-constraint 1: no contributor email reaches the org before acceptance. - **Anti-oracle guard.** Only *visible contributors* of the project may be invited (reusing the Community tab's member / deleted / disabled exclusions, via a single shared `VISIBLE_CONTRIBUTOR` query). Every other id — nonexistent, deleted, disabled, member, org member, or a contributor of another project — returns one **byte-identical 404**, so the endpoint leaks neither account existence nor state. - **No permission-less membership.** `type = NONE` is rejected server-side (`CANNOT_INVITE_CONTRIBUTOR_WITH_NO_PERMISSION`), not merely hidden in the picker. - Scoped/advanced invites route through the EE service exactly like the email invite; `agencyId` stays null (a non-null agency would short-circuit acceptance binding). - Acceptance is bound to the invitee (`email = user.username`); a different user gets `INVITATION_EMAIL_MISMATCH`. ## Frontend - `members.edit`-gated **"Invite as member"** button on the contributor row, opening the existing `PermissionsModal` picker with `hideNone` (matching the email dialog). The permission→body mapping is factored into a shared helper used by both invite hooks. ## Tests - Backend (`InviteContributorControllerTest`, `InviteContributorEeTest`): email stored + hidden, model + public-endpoint email null, `MEMBERS_EDIT` gate, uniform-404 for every non-visible-contributor id, re-invite dedup, NONE rejection, EE scoped routing + granted languages, nameless-contributor path, acceptance binding + granted membership. - Frontend: vitest over the shared permission-body helper (all role/scope/language branches, incl. scope-based filtering); an E2E case that invites a contributor from the Community tab (typed `data-cy` selectors), asserts the request succeeds, that a pending invitation appears, and that the `NONE` role is absent from the picker. ## Notes - Accepted, documented limitations (see the plan's Risks): an already-invited contributor stays listed with a live button until they accept (a second click fails safely with a no-email error), and acceptance is email-bound rather than user-FK-bound (a full user-FK model is deferred to a follow-up across all invitation kinds).
…rs list (#3823) Strip a user's `username` (which is their e-mail) from every API response except the surfaces where disclosure is legitimate: after this change one user can see another's `username` only where the caller holds `MEMBERS_VIEW` AND the target is a current member (plus org-members list, self, instance-admin, and the admin-only export ZIP). `SecurityService.maskedMemberField` gated only on the caller's `MEMBERS_VIEW`, never on target membership, and failed open off-request/off-project — so project admins on public projects saw community contributors' and ex-members' raw e-mails, and non-project-scoped endpoints leaked to anyone. Stripping the field makes the rule hold structurally instead of re-checking membership on ~11 author-bearing surfaces. `username` is now a deprecated always-empty field on the stripped models (kept on the wire so clients don't break); a classpath-scanning `UsernameDisclosureGuard` test fails CI if any non-sanctioned response model reintroduces it. Frontend already prefers name + avatar for identity off the members/org/admin surfaces.
34a4bce to
b01fbe2
Compare
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (6)
webapp/src/views/projects/members/component/getInvitationPermissionBody.test.ts (1)
2-2: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the
tg.viewsalias for this module.
./getInvitationPermissionBodybypasses the required Tolgee path aliases.Proposed fix
-import { getInvitationPermissionBody } from './getInvitationPermissionBody'; +import { getInvitationPermissionBody } from 'tg.views/projects/members/component/getInvitationPermissionBody';As per coding guidelines, “Use Tolgee custom TypeScript path aliases (tg.component, tg.service, tg.hooks, tg.views, tg.globalContext) instead of relative imports.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/component/getInvitationPermissionBody.test.ts` at line 2, Update the import of getInvitationPermissionBody in the test to use the tg.views path alias instead of the relative ./getInvitationPermissionBody path, preserving the existing imported symbol and behavior.Source: Coding guidelines
webapp/src/views/projects/members/component/useCreateInvitation.tsx (1)
3-6: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the Tolgee path alias for the shared helper.
Replace the new relative import with
tg.views/projects/members/component/getInvitationPermissionBody. As per coding guidelines, “Use Tolgee custom TypeScript path aliases … instead of relative imports.”Proposed fix
-import { getInvitationPermissionBody } from './getInvitationPermissionBody'; +import { getInvitationPermissionBody } from 'tg.views/projects/members/component/getInvitationPermissionBody';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/component/useCreateInvitation.tsx` around lines 3 - 6, Update the getInvitationPermissionBody import in useCreateInvitation.tsx to use the Tolgee tg.views/projects/members/component/getInvitationPermissionBody path alias instead of the relative path, leaving the other imports unchanged.Source: Coding guidelines
webapp/src/views/projects/members/component/useUpdatePermissions.ts (1)
4-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse a Tolgee path alias instead of a relative import.
This new relative import violates the webapp convention. Import
permissionBodythrough the appropriatetg.views/...alias.As per coding guidelines, webapp TypeScript files must use Tolgee custom TypeScript path aliases instead of relative imports.
Proposed import
-import { languagePermissionsForRole } from './permissionBody'; +import { languagePermissionsForRole } from 'tg.views/projects/members/component/permissionBody';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/component/useUpdatePermissions.ts` at line 4, Update the import of languagePermissionsForRole in useUpdatePermissions.ts to use the established tg.views/... Tolgee path alias for permissionBody instead of the relative './permissionBody' path.Source: Coding guidelines
webapp/src/component/PermissionsSettings/hierarchyTools.ts (1)
122-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPlace the new caller before
getScopeLanguagePermission.
scopesToLanguagePermissionscallsgetScopeLanguagePermission, but the caller is declared below the callee. Move the new helper above it to follow the required Stepdown ordering.As per path instructions, functions should be ordered so that a caller appears before the functions it calls.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/component/PermissionsSettings/hierarchyTools.ts` around lines 122 - 134, Move the scopesToLanguagePermissions helper above getScopeLanguagePermission, preserving its implementation and exports, so the caller appears before the function it invokes according to the required stepdown ordering.Source: Path instructions
webapp/src/views/projects/members/component/getInvitationPermissionBody.ts (1)
8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse the required
tg.viewsaliases. These local relative imports violate the webapp alias convention.
webapp/src/views/projects/members/component/getInvitationPermissionBody.ts#L8-L8: importpermissionBodythrough the appropriatetg.views/...alias.webapp/src/views/projects/members/component/useCreateContributorInvitation.ts#L3-L3: importgetInvitationPermissionBodythrough the appropriatetg.views/...alias.As per coding guidelines, “Use Tolgee custom TypeScript path aliases (tg.component, tg.service, tg.hooks, tg.views, tg.globalContext) instead of relative imports.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/component/getInvitationPermissionBody.ts` at line 8, Replace the relative import in getInvitationPermissionBody.ts at line 8 with the appropriate tg.views alias for permissionBody, and replace the relative getInvitationPermissionBody import in useCreateContributorInvitation.ts at line 3 with its tg.views alias. Preserve the imported symbols and behavior.Source: Coding guidelines
webapp/src/views/projects/members/component/ContributorItem.tsx (1)
17-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the configured Tolgee path alias.
Replace the relative hook import with its
tg.viewsalias.Proposed fix
-import { useCreateContributorInvitation } from './useCreateContributorInvitation'; +import { useCreateContributorInvitation } from 'tg.views/projects/members/component/useCreateContributorInvitation';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@webapp/src/views/projects/members/component/ContributorItem.tsx` at line 17, Update the useCreateContributorInvitation import in ContributorItem.tsx to use the configured tg.views path alias instead of the relative path, leaving the hook usage unchanged.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@backend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.kt`:
- Line 56: Update the actor payload construction in ActivityWebsocketListener
around simpleUserAccountModelAssembler.toModel(user) to redact the username
before sending websocket events, restoring the previous copy(username = "")
behavior or using an explicitly allowlisted actor DTO while preserving the other
assembled fields.
In
`@backend/app/src/test/kotlin/io/tolgee/service/project/ProjectHardDeletingServiceTest.kt`:
- Around line 176-189: Replace the native SQL contributor insert in
ProjectHardDeletingServiceTest with the appropriate contributor TestData
fixture, linking it to the existing project and user from BaseTestData. Persist
the fixture through testDataService alongside testData.root, and remove the
executeInNewTransaction/native-query setup.
In `@backend/data/src/test/kotlin/io/tolgee/unit/ShouldExposeMemberCountTest.kt`:
- Around line 13-23: Update the test `shouldExposeMemberCount is true
off-request without touching the project holder` to explicitly clear
`RequestContextHolder` before invoking `shouldExposeMemberCount()`, and restore
the previous request attributes afterward, using setup/teardown or a scoped
cleanup mechanism so thread-local state is preserved.
In
`@backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt`:
- Around line 195-210: Update recordProjectActivity to use the existing
setForcedDate helper when applying at, so any existing JWT is refreshed, and
restore the prior forced-date state after the transaction completes. Ensure
restoration occurs even when executeInNewTransaction or persistence fails, using
the existing date helpers rather than mutating currentDateProvider.forcedDate
directly.
In `@e2e/cypress/e2e/translations/softDeleteKeys.cy.ts`:
- Line 229: Replace the text-based selector in the user-selection flow with a
unique data-cy target keyed to the user ID, add that attribute to the rendered
user element if needed, and select it through cy.gcy().
---
Nitpick comments:
In `@webapp/src/component/PermissionsSettings/hierarchyTools.ts`:
- Around line 122-134: Move the scopesToLanguagePermissions helper above
getScopeLanguagePermission, preserving its implementation and exports, so the
caller appears before the function it invokes according to the required stepdown
ordering.
In `@webapp/src/views/projects/members/component/ContributorItem.tsx`:
- Line 17: Update the useCreateContributorInvitation import in
ContributorItem.tsx to use the configured tg.views path alias instead of the
relative path, leaving the hook usage unchanged.
In
`@webapp/src/views/projects/members/component/getInvitationPermissionBody.test.ts`:
- Line 2: Update the import of getInvitationPermissionBody in the test to use
the tg.views path alias instead of the relative ./getInvitationPermissionBody
path, preserving the existing imported symbol and behavior.
In `@webapp/src/views/projects/members/component/getInvitationPermissionBody.ts`:
- Line 8: Replace the relative import in getInvitationPermissionBody.ts at line
8 with the appropriate tg.views alias for permissionBody, and replace the
relative getInvitationPermissionBody import in useCreateContributorInvitation.ts
at line 3 with its tg.views alias. Preserve the imported symbols and behavior.
In `@webapp/src/views/projects/members/component/useCreateInvitation.tsx`:
- Around line 3-6: Update the getInvitationPermissionBody import in
useCreateInvitation.tsx to use the Tolgee
tg.views/projects/members/component/getInvitationPermissionBody path alias
instead of the relative path, leaving the other imports unchanged.
In `@webapp/src/views/projects/members/component/useUpdatePermissions.ts`:
- Line 4: Update the import of languagePermissionsForRole in
useUpdatePermissions.ts to use the established tg.views/... Tolgee path alias
for permissionBody instead of the relative './permissionBody' path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: cbe401ce-679e-4ecb-84cf-1b7bc0b1d873
⛔ Files ignored due to path filters (2)
webapp/src/service/apiSchema.generated.tsis excluded by!**/*.generated.*webapp/src/service/apiSchemaTypes.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (127)
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectContributorsController.ktbackend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityAuthorModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyWithLanguagesModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/IApiKeyModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/RevealedApiKeyModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/invitation/OrganizationInvitationModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/invitation/ProjectInvitationModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/invitation/PublicInvitationModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.ktbackend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.ktbackend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.ktbackend/app/src/test/kotlin/io/tolgee/activity/ActivityLogTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ApiKeyControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InitialDataControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InviteContributorControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorBackfillTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorsControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/notification/NotificationControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/TranslationCommentControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerHistoryTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyTrashFilterTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyTrashPermissionsTest.ktbackend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.ktbackend/app/src/test/kotlin/io/tolgee/contributors/ContributorActivityRecorder.ktbackend/app/src/test/kotlin/io/tolgee/hateoas/UsernameDisclosureGuardCoreTest.ktbackend/app/src/test/kotlin/io/tolgee/hateoas/UsernameDisclosureGuardValueTest.ktbackend/app/src/test/kotlin/io/tolgee/service/UserAccountServiceTest.ktbackend/app/src/test/kotlin/io/tolgee/service/project/ProjectHardDeletingServiceTest.ktbackend/data/src/main/kotlin/io/tolgee/activity/ActivityService.ktbackend/data/src/main/kotlin/io/tolgee/activity/projectActivity/ActivityViewByRevisionsProvider.ktbackend/data/src/main/kotlin/io/tolgee/api/IProjectActivityAuthorModel.ktbackend/data/src/main/kotlin/io/tolgee/api/UsernameFieldDeprecation.ktbackend/data/src/main/kotlin/io/tolgee/constants/Message.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/CommunityContributionE2eData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.ktbackend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SoftDeleteKeysTestData.ktbackend/data/src/main/kotlin/io/tolgee/dtos/misc/CreateProjectInvitationParams.ktbackend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.ktbackend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectInviteContributorDto.ktbackend/data/src/main/kotlin/io/tolgee/dtos/response/UserResponseDTO.ktbackend/data/src/main/kotlin/io/tolgee/model/Invitation.ktbackend/data/src/main/kotlin/io/tolgee/model/UserAccount.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributor.ktbackend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributorId.ktbackend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.ktbackend/data/src/main/kotlin/io/tolgee/model/views/ProjectContributorView.ktbackend/data/src/main/kotlin/io/tolgee/model/views/TranslationSuggestionView.ktbackend/data/src/main/kotlin/io/tolgee/model/views/activity/ProjectActivityView.ktbackend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectContributorRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.ktbackend/data/src/main/kotlin/io/tolgee/repository/activity/ActivityModifiedEntityRepository.ktbackend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.ktbackend/data/src/main/kotlin/io/tolgee/service/invitation/InvitationService.ktbackend/data/src/main/kotlin/io/tolgee/service/key/KeySearchResultView.ktbackend/data/src/main/kotlin/io/tolgee/service/project/ProjectHardDeletingService.ktbackend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.ktbackend/data/src/main/kotlin/io/tolgee/service/projectExportImport/ProjectExportImportPolicyRegistry.ktbackend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.ktbackend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.ktbackend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.ktbackend/data/src/main/resources/db/changelog/projectContributorBackfill.sqlbackend/data/src/main/resources/db/changelog/projectContributorTrigger.sqlbackend/data/src/main/resources/db/changelog/schema.xmlbackend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.ktbackend/data/src/test/kotlin/io/tolgee/unit/ShouldExposeMemberCountTest.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.ktbackend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.ktbackend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.ktbackend/testing/src/main/kotlin/io/tolgee/testing/security/UsernameDisclosureGuard.kte2e/cypress/common/apiCalls/testData/testData.tse2e/cypress/e2e/projects/communityMembersTab.cy.tse2e/cypress/e2e/projects/communityProjectsNavigation.cy.tse2e/cypress/e2e/translations/softDeleteKeys.cy.tse2e/cypress/support/dataCyType.d.tsee/backend/app/src/main/kotlin/io/tolgee/ee/component/TaskReportHelper.ktee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TranslationSuggestionRepository.ktee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/CommunitySuggestionTest.ktee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/InviteContributorEeTest.ktee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.ktee/backend/tests/src/test/kotlin/io/tolgee/ee/component/TaskReportHelperTest.ktee/backend/tests/src/test/kotlin/io/tolgee/hateoas/UsernameDisclosureGuardEeTest.ktwebapp/src/component/PermissionsSettings/PermissionsModal.tsxwebapp/src/component/PermissionsSettings/PermissionsRole.tsxwebapp/src/component/PermissionsSettings/hierarchyTools.tswebapp/src/component/common/UserName.tsxwebapp/src/component/organizationSwitch/OrganizationSwitch.tsxwebapp/src/globalContext/helpers.tsxwebapp/src/views/organizations/components/useUpdateBasePermissions.tswebapp/src/views/projects/CommunityProjectsView.tsxwebapp/src/views/projects/DashboardProjectListItem.tsxwebapp/src/views/projects/MyContributionsToggle.tsxwebapp/src/views/projects/members/ProjectMembersView.tsxwebapp/src/views/projects/members/component/ContributorItem.tsxwebapp/src/views/projects/members/component/InvitationItem.tsxwebapp/src/views/projects/members/component/InviteDialog.tsxwebapp/src/views/projects/members/component/getInvitationPermissionBody.test.tswebapp/src/views/projects/members/component/getInvitationPermissionBody.tswebapp/src/views/projects/members/component/permissionBody.tswebapp/src/views/projects/members/component/useCreateContributorInvitation.tswebapp/src/views/projects/members/component/useCreateInvitation.tsxwebapp/src/views/projects/members/component/useUpdatePermissions.tswebapp/src/views/projects/usePublicProjectsList.ts
💤 Files with no reviewable changes (15)
- backend/data/src/main/kotlin/io/tolgee/service/key/KeySearchResultView.kt
- backend/data/src/main/kotlin/io/tolgee/activity/projectActivity/ActivityViewByRevisionsProvider.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyModelAssembler.kt
- backend/data/src/main/kotlin/io/tolgee/model/views/TranslationSuggestionView.kt
- backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/TranslationHistoryView.kt
- backend/data/src/main/kotlin/io/tolgee/dtos/response/UserResponseDTO.kt
- backend/data/src/main/kotlin/io/tolgee/model/views/activity/ProjectActivityView.kt
- backend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.kt
- backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.kt
🚧 Files skipped from review as they are similar to previous changes (36)
- backend/data/src/main/kotlin/io/tolgee/model/views/ProjectContributorView.kt
- backend/app/src/test/kotlin/io/tolgee/contributors/ContributorActivityRecorder.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModel.kt
- backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt
- backend/data/src/main/resources/db/changelog/projectContributorBackfill.sql
- backend/api/src/main/kotlin/io/tolgee/hateoas/contributor/ContributorModelAssembler.kt
- backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt
- backend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributor.kt
- e2e/cypress/common/apiCalls/testData/testData.ts
- backend/data/src/main/kotlin/io/tolgee/model/contributor/ProjectContributorId.kt
- backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/CommunityContributionE2eDataController.kt
- backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt
- backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kt
- webapp/src/globalContext/helpers.tsx
- backend/data/src/main/kotlin/io/tolgee/activity/ActivityService.kt
- backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt
- webapp/src/views/projects/CommunityProjectsView.tsx
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NullTypedActivityRevisionStorageTest.kt
- backend/data/src/main/kotlin/io/tolgee/service/projectExportImport/ProjectExportImportPolicyRegistry.kt
- backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt
- backend/data/src/main/kotlin/io/tolgee/service/contributor/ProjectContributorService.kt
- backend/data/src/main/resources/db/changelog/projectContributorTrigger.sql
- webapp/src/views/projects/MyContributionsToggle.tsx
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt
- backend/app/src/test/kotlin/io/tolgee/service/UserAccountServiceTest.kt
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt
- backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt
- webapp/src/views/projects/members/ProjectMembersView.tsx
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/InitialDataControllerTest.kt
- backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/CommunityContributionE2eData.kt
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorBackfillTest.kt
- backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectContributorsControllerTest.kt
- e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts
- backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt
| val testData = BaseTestData() | ||
| testDataService.saveTestData(testData.root) | ||
| val projectId = testData.projectBuilder.self.id | ||
| val userId = testData.user.id | ||
|
|
||
| executeInNewTransaction(platformTransactionManager) { | ||
| entityManager | ||
| .createNativeQuery( | ||
| "insert into project_contributor (project_id, user_id, first_contribution_at, last_contribution_at) " + | ||
| "values (:projectId, :userId, now(), now())", | ||
| ).setParameter("projectId", projectId) | ||
| .setParameter("userId", userId) | ||
| .executeUpdate() | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use a TestData fixture for the contributor row.
Line 181 seeds test state through native SQL, bypassing the required TestData lifecycle. Represent this setup through a contributor TestData fixture and persist it with testDataService.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@backend/app/src/test/kotlin/io/tolgee/service/project/ProjectHardDeletingServiceTest.kt`
around lines 176 - 189, Replace the native SQL contributor insert in
ProjectHardDeletingServiceTest with the appropriate contributor TestData
fixture, linking it to the existing project and user from BaseTestData. Persist
the fixture through testDataService alongside testData.root, and remove the
executeInNewTransaction/native-query setup.
Source: Coding guidelines
| @Test | ||
| fun `shouldExposeMemberCount is true off-request without touching the project holder`() { | ||
| val projectHolder = | ||
| mock<ProjectHolder> { | ||
| whenever(it.projectOrNull).thenThrow(IllegalStateException("project holder must not be read off-request")) | ||
| } | ||
| val securityService = | ||
| SecurityService(mock(), mock(), mock(), projectHolder = projectHolder, branchService = mock()) | ||
|
|
||
| assertThat(securityService.shouldExposeMemberCount()).isTrue() | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Isolate the off-request test from inherited request state.
Clear RequestContextHolder before the assertion and restore it afterward; otherwise a prior test on the same thread can invalidate this test’s premise.
Proposed fix
fun `shouldExposeMemberCount is true off-request without touching the project holder`() {
+ val previousAttributes = RequestContextHolder.getRequestAttributes()
+ RequestContextHolder.resetRequestAttributes()
+ try {
val projectHolder =
mock<ProjectHolder> {
whenever(it.projectOrNull).thenThrow(IllegalStateException("project holder must not be read off-request"))
}
val securityService =
SecurityService(mock(), mock(), mock(), projectHolder = projectHolder, branchService = mock())
assertThat(securityService.shouldExposeMemberCount()).isTrue()
+ } finally {
+ if (previousAttributes == null) RequestContextHolder.resetRequestAttributes()
+ else RequestContextHolder.setRequestAttributes(previousAttributes)
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Test | |
| fun `shouldExposeMemberCount is true off-request without touching the project holder`() { | |
| val projectHolder = | |
| mock<ProjectHolder> { | |
| whenever(it.projectOrNull).thenThrow(IllegalStateException("project holder must not be read off-request")) | |
| } | |
| val securityService = | |
| SecurityService(mock(), mock(), mock(), projectHolder = projectHolder, branchService = mock()) | |
| assertThat(securityService.shouldExposeMemberCount()).isTrue() | |
| } | |
| `@Test` | |
| fun `shouldExposeMemberCount is true off-request without touching the project holder`() { | |
| val previousAttributes = RequestContextHolder.getRequestAttributes() | |
| RequestContextHolder.resetRequestAttributes() | |
| try { | |
| val projectHolder = | |
| mock<ProjectHolder> { | |
| whenever(it.projectOrNull).thenThrow(IllegalStateException("project holder must not be read off-request")) | |
| } | |
| val securityService = | |
| SecurityService(mock(), mock(), mock(), projectHolder = projectHolder, branchService = mock()) | |
| assertThat(securityService.shouldExposeMemberCount()).isTrue() | |
| } finally { | |
| if (previousAttributes == null) RequestContextHolder.resetRequestAttributes() | |
| else RequestContextHolder.setRequestAttributes(previousAttributes) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/data/src/test/kotlin/io/tolgee/unit/ShouldExposeMemberCountTest.kt`
around lines 13 - 23, Update the test `shouldExposeMemberCount is true
off-request without touching the project holder` to explicitly clear
`RequestContextHolder` before invoking `shouldExposeMemberCount()`, and restore
the previous request attributes afterward, using setup/teardown or a scoped
cleanup mechanism so thread-local state is preserved.
| protected fun recordProjectActivity( | ||
| authorId: Long?, | ||
| projectId: Long, | ||
| at: Date? = null, | ||
| ) { | ||
| at?.let { currentDateProvider.forcedDate = it } | ||
| executeInNewTransaction { | ||
| entityManager.persist( | ||
| ActivityRevision().apply { | ||
| this.projectId = projectId | ||
| this.authorId = authorId | ||
| }, | ||
| ) | ||
| entityManager.flush() | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Restore the test clock through the existing date helpers.
Line 200 mutates the shared clock without restoring it, so later setup or authenticated requests can inherit a stale timestamp. It also bypasses setForcedDate, which refreshes an existing JWT.
Proposed fix
protected fun recordProjectActivity(
authorId: Long?,
projectId: Long,
at: Date? = null,
) {
- at?.let { currentDateProvider.forcedDate = it }
- executeInNewTransaction {
- entityManager.persist(
- ActivityRevision().apply {
- this.projectId = projectId
- this.authorId = authorId
- },
- )
- entityManager.flush()
+ val previousForcedDate = currentDateProvider.forcedDate
+ try {
+ at?.let { setForcedDate(it) }
+ executeInNewTransaction {
+ entityManager.persist(
+ ActivityRevision().apply {
+ this.projectId = projectId
+ this.authorId = authorId
+ },
+ )
+ entityManager.flush()
+ }
+ } finally {
+ previousForcedDate?.let { setForcedDate(it) } ?: clearForcedDate()
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| protected fun recordProjectActivity( | |
| authorId: Long?, | |
| projectId: Long, | |
| at: Date? = null, | |
| ) { | |
| at?.let { currentDateProvider.forcedDate = it } | |
| executeInNewTransaction { | |
| entityManager.persist( | |
| ActivityRevision().apply { | |
| this.projectId = projectId | |
| this.authorId = authorId | |
| }, | |
| ) | |
| entityManager.flush() | |
| } | |
| } | |
| protected fun recordProjectActivity( | |
| authorId: Long?, | |
| projectId: Long, | |
| at: Date? = null, | |
| ) { | |
| val previousForcedDate = currentDateProvider.forcedDate | |
| try { | |
| at?.let { setForcedDate(it) } | |
| executeInNewTransaction { | |
| entityManager.persist( | |
| ActivityRevision().apply { | |
| this.projectId = projectId | |
| this.authorId = authorId | |
| }, | |
| ) | |
| entityManager.flush() | |
| } | |
| } finally { | |
| previousForcedDate?.let { setForcedDate(it) } ?: clearForcedDate() | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt`
around lines 195 - 210, Update recordProjectActivity to use the existing
setForcedDate helper when applying at, so any existing JWT is refreshed, and
restore the prior forced-date state after the transaction completes. Ensure
restoration occurs even when executeInNewTransaction or persistence fails, using
the existing date helpers rather than mutating currentDateProvider.forcedDate
directly.
| cy.gcy('translations-filter-select').click(); | ||
| cy.gcy('submenu-item').contains('Deleted by').click(); | ||
| cy.gcy('filter-item').contains(users[0].username).click(); | ||
| cy.gcy('filter-item').contains(users[0].name).click(); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Replace the text-based Cypress selector.
Line 229 selects by rendered name; add a unique data-cy target (for example, keyed by user ID) and select it through cy.gcy().
As per coding guidelines, “STRICTLY use data-cy attributes for E2E selectors, never rely on text content; use typed helpers gcy() or cy.gcy()”.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@e2e/cypress/e2e/translations/softDeleteKeys.cy.ts` at line 229, Replace the
text-based selector in the user-selection flow with a unique data-cy target
keyed to the user ID, add that attribute to the rendered user element if needed,
and select it through cy.gcy().
Source: Coding guidelines
Integration branch for pitch #3806 — Community translation v1.1 (Contributors).
Pitch: #3806
All parts of the pitch land on this branch: each remaining part opens its own PR
targeting
jirikuchynka/community-contributors(notmain), and this PR merges tomainonce the pitch is complete. Scope A (below) is the first part landed here.Scope A — Contributor tracking + list endpoint (backend) ✅
A contributor = someone who did any modifying action (per the activity log) on a
project and is not currently a member of it.
project_contributorcache table, maintained by a PostgresAFTER INSERTtriggeron
activity_revision— a single-row upsert per contribution (LEAST/GREATESTonthe timestamp), atomic with the activity write. Like
activity_revisionit carriesno foreign keys, so it mirrors the append-only log; its rows outlive a deleted
project/user and the read query filters them out.
rule; excludes historical null-typed no-op revisions).
GET /v2/projects/{projectId}/contributors— paged,MEMBERS_VIEW+ super-auth,@AllowApiAccess, email-free (name + avatar only). Excludes current members(direct permission or org role), deleted and disabled users. Sort allowlist
(
lastContributionAt/firstContributionAt) with auser_idtiebreaker.persisted, so
revision exists ⇒ real contribution.project_contributor(user_id)for the upcoming per-user query (scope C);the per-project read is served by the
(project_id, user_id)primary key.Design and changeset were converged through the full multi-reviewer thorough-review loop.
Scope C —
/community-projectspage: toggle + switcher entry (#3820) ✅Reads the
project_contributortable from the per-user direction.contributed to but is not a member of; off lists all public projects. Sits in the
header's add-button slot (where the normal list's add button lives). Served by an
opt-in
filterContributedparam onGET /v2/public/projects/with-stats, so theanonymous public-homepage list that shares the hook is unchanged.
InitialDataModel.hasCommunityContributionsflag — a cheapEXISTSover theproject_contributor(user_id)index, piggybacked on the cached initial-data payload(no extra request). Appears exactly when the community page has something to show.
NON_MEMBER_CONTRIBUTOR_FILTERconstant, so they cannot diverge.
communityProjectsNavigation.cy.tstests, plus newdefault-on and negative-branch (no-contributions → no entry) coverage.
Converged through the full multi-reviewer thorough-review loop.
Scope B — Contributors on the Members page (#3821) ✅
Reads the scope-A contributors endpoint and surfaces it on the Members page.
contributor; the tab selection lives in the URL (
?tab=) like the page'sagency filter. When the gate is not met the member list renders unchanged.
contribution dates — email-free by construction (no email in the model,
row, or payload; the member assembler's masking is not reused).
public-empty / private), Team↔Community mutual exclusivity, and the no-email
assertion, driven off the shared
ContributorsTestDatafixture via a new e2edata controller.
cleanTestDatalooked users up viafindActive, so deleted/disabled fixture users leaked and collided on thenext generate; it now tears them down regardless of state.
Converged through the full multi-reviewer thorough-review loop.
Scope D — Invite a contributor as a member (#3825) ✅
The optional invite path from the Community row: turn a listed contributor into a
pending member without ever exposing their email.
PUT /v2/projects/{projectId}/invite-contributor— the request carries auser id, not an address. The server resolves the target through the same
VISIBLE_CONTRIBUTORfilter the list uses (findVisibleContributor), so a caller canonly invite someone the contributors list would already show them. A non-visible id
(member / deleted / disabled / non-contributor) returns the same uniform 404 as a
missing project — the endpoint is not a membership/existence oracle.
Invitation.emailHiddenflag gates avisibleEmailgetter that every invitationassembler (project / public / organization) now reads — so a pending user-targeted
invitation never republishes the address onto the Members page. The reveal is gated,
not the storage:
validateInvitationEmailearly-returns on a blank email, sodropping storage would turn these into shareable invite codes.
NONE-permissioninvite is rejected (
CANNOT_INVITE_CONTRIBUTOR_WITH_NO_PERMISSION).members.edit; opens thestandard permissions modal with the
NONErole hidden.invite request shape.
Converged through the full multi-reviewer thorough-review loop.
Hardening — stop disclosing user e-mail (username) platform-wide (#3823) ✅
Not a planned pitch scope — surfaced by the pitch's own risk that
maskedMemberFielddoes not protect us. A user's
usernameis their e-mail, and the old masking gatedonly on the caller holding
MEMBERS_VIEW, never on the target being a member, andfailed open off-request/off-project. So a project admin on a public project saw the raw
e-mail of community contributors and ex-members across the activity feed, suggestions,
comments, history, tasks, and key-trash; non-project-scoped endpoints (notifications,
co-assignees) leaked to anyone.
usernamestripped to a deprecated always-empty field on every model that merelyreferences a user (
SimpleUserAccountModel— which backs ~16 assemblers — plus theactivity-author and api-key models). Kept on the wire so existing clients don't break.
The e-mail survives only where it is legitimate: project-members list, org-members list,
self/
me, instance-admin user management, and the admin-only export ZIP (import re-linksauthors by username).
UsernameDisclosureGuard— a classpath-scanning test (core + EE entrypoints) failsCI if any non-sanctioned response model reintroduces a
username, so the invariant cannotsilently regress as new author-bearing surfaces are added.
author-email view fields the review surfaced.
Converged through the full multi-reviewer thorough-review loop.
Remaining parts
Design notes (rationale moved out of code comments)
Backfill runs inline at boot — why that is acceptable. The one-time historical
backfill (
projectContributorBackfill.sql) aggregatesactivity_revision, ahigh-row-count table, in a single Liquibase transaction at startup. Kept on the boot
path deliberately:
activity_revisionis high-row-count but narrow — the large jsonbpayloads live in child tables (
activity_modified_entity/activity_describing_entity),not here — so this is a single seq-scan + hash-aggregate: seconds to low-minutes even on
the largest known instance (~1e7 rows). The
EXISTSis served by theactivity_modified_entityFK index.activity_revisionwriters.ON CONFLICTLEAST/GREATEST), so a re-run after a raisedstatement_timeoutis safe.track_project_contributortrigger is installed by thepreceding changeset, so all go-forward writes are already captured — the backfill is
pure historical catch-up and could be run out of band if a deployment ever needed to
skip it. The residual all-or-nothing boot-block risk on a pathologically large instance
is accepted; operators can raise
statement_timeoutif ever needed.Staff are counted as contributors. The membership exclusion filters on membership
rows only (a
PermissionorOrganizationRoleon the project). A globalADMIN/SUPPORTERwho edits a project without holding one is intentionally listed — the pitch definition is
"acted and is not a member", with no staff carve-out. Excluding them would be a one-line
u.rolefilter onfindContributors/NON_MEMBER_CONTRIBUTOR_FILTERif ever desired.project_contributorlifecycle. The table is FK-free (mirroringactivity_revision).Its rows are reclaimed by
ProjectHardDeletingServicewhen a project is hard-deleted;users are only ever soft-deleted, so their rows persist and are filtered at read.
Hot-path flag query shape.
hasCommunityContributions(on every authenticatedinitial-data load) is
hasNonMemberPublicContribution, written to drive from theproject_contributor(user_id)index — it touches only the user's few contribution rowsrather than scanning every public project.
Summary by CodeRabbit