Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/DESIGN_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ Each section's service owns these tables. Cross-service access goes through the
| **Budget** | `BudgetService` | `budget_years`, `budget_groups`, `budget_categories`, `budget_line_items`, `budget_audit_logs`, `ticketing_projections` |
| **Tickets** | `TicketQueryService`, `TicketSyncService`, `TicketingBudgetService` | `ticket_orders`, `ticket_attendees`, `ticket_sync_states` |
| **Campaigns** | `CampaignService` | `campaigns`, `campaign_codes`, `campaign_grants` |
| **Google Integration** | `GoogleSyncService`, `GoogleAdminService`, `GoogleWorkspaceUserService`, `DriveActivityMonitorService`, `SyncSettingsService`, `EmailProvisioningService` | `google_resources`, `sync_service_settings` |
| **Team Resources** | `TeamResourceService` | `google_resources` |
| **Google Integration** | `GoogleSyncService`, `GoogleAdminService`, `GoogleWorkspaceUserService`, `DriveActivityMonitorService`, `SyncSettingsService`, `EmailProvisioningService` | `sync_service_settings` |
| **Email** | `EmailOutboxService`, `EmailService` | `email_outbox_messages` |
| **Feedback** | `FeedbackService` | `feedback_reports` |
| **Notifications** | `NotificationService`, `NotificationInboxService` | *(in-memory / transient)* |
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ jobs:

- name: Check formatting
run: dotnet format Humans.slnx --verify-no-changes --verbosity diagnostic --exclude src/Humans.Infrastructure/Migrations/

- name: Enforce google_resources ownership
run: bash scripts/check-google-resource-ownership.sh
10 changes: 3 additions & 7 deletions docs/architecture/service-data-access-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,9 @@ No direct cache. Delegates to other services.
|-------|-----|
| GoogleResources | R/W |

No cache.

### TeamResourcePersistence (static helper, not DI-registered)

| Table | R/W |
|-------|-----|
| GoogleResources | R/W |
Sole owner of the `google_resources` table. All consumers call
`ITeamResourceService` read methods rather than touching `DbSet<GoogleResource>`
directly; ownership is enforced by `scripts/check-google-resource-ownership.sh`.

No cache.

Expand Down
68 changes: 68 additions & 0 deletions scripts/check-google-resource-ownership.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Guardrail: enforce that google_resources is accessed only through
# TeamResourceService (the owning service), per .claude/DESIGN_RULES.md.
#
# This script fails if any file under src/ touches DbSet<GoogleResource>
# or _dbContext.GoogleResources / dbContext.GoogleResources / db.GoogleResources
# outside the allowlisted owner files. Migration files and the DbContext
# itself are always allowed.
#
# Phase 2 exceptions: GoogleWorkspaceSyncService, SystemTeamSyncJob,
# ProcessGoogleSyncOutboxJob, and GoogleController still touch the table
# directly — they are tracked as temporary exceptions while the remainder
# of the Google sync service is decomposed. Remove entries from
# PHASE2_EXCEPTIONS as each caller migrates to ITeamResourceService reads.

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"

# Files that are allowed to touch the DbSet<GoogleResource> directly.
ALLOWED=(
"src/Humans.Infrastructure/Services/TeamResourceService.cs"
"src/Humans.Infrastructure/Services/StubTeamResourceService.cs"
"src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs"
"src/Humans.Infrastructure/Data/HumansDbContext.cs"
)

# Phase 2: still-pending migrations to services that own writes here.
# Track the upstream issue before adding anything to this list.
PHASE2_EXCEPTIONS=(
"src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs"
"src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs"
"src/Humans.Infrastructure/Jobs/ProcessGoogleSyncOutboxJob.cs"
"src/Humans.Web/Controllers/GoogleController.cs"
)

build_exclude_args() {
local args=()
for f in "${ALLOWED[@]}" "${PHASE2_EXCEPTIONS[@]}"; do
args+=(":(exclude)$f")
done
printf '%s\n' "${args[@]}"
}

mapfile -t EXCLUDE_ARGS < <(build_exclude_args)

# Dangerous patterns: direct DbSet<GoogleResource> access through a DbContext
# variable. We allow view-model/property/route names that also happen to
# contain the word "GoogleResources" — those never look like a DbContext
# member access.
PATTERN='(DbSet<GoogleResource>|(_dbContext|dbContext|db|_db)\.GoogleResources)'

MATCHES=$(git grep -n -E "$PATTERN" -- 'src/**' "${EXCLUDE_ARGS[@]}" || true)

if [[ -n "$MATCHES" ]]; then
echo "error: google_resources direct access outside TeamResourceService:" >&2
echo "$MATCHES" >&2
echo >&2
echo "google_resources is owned by TeamResourceService. Use one of its read" >&2
echo "methods (GetTeamResourcesAsync, GetResourcesByTeamIdsAsync," >&2
echo "GetTeamResourceSummariesAsync, GetActiveResourceCountsByTeamAsync," >&2
echo "GetUserTeamResourcesAsync, GetActiveDriveFoldersAsync, GetResourceCountAsync)" >&2
echo "instead of reaching into the DbSet directly. See .claude/DESIGN_RULES.md." >&2
exit 1
fi

echo "ok: google_resources access is confined to TeamResourceService."
67 changes: 66 additions & 1 deletion src/Humans.Application/Interfaces/ITeamResourceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,83 @@

namespace Humans.Application.Interfaces;

/// <summary>
/// Aggregate summary of a team's Google resources, used by admin listings that need
/// per-team flags/counts without loading the full resource rows.
/// </summary>
public record TeamResourceSummary(bool HasMailGroup, int DriveResourceCount)
{
public static TeamResourceSummary Empty { get; } = new(false, 0);
}

/// <summary>
/// A Google resource projection joined with its owning team, used by the dashboard
/// "My Google Resources" widget. Owned by ITeamResourceService so callers do not
/// need to reach across the team ↔ resource boundary.
/// </summary>
public record UserTeamGoogleResource(
string TeamName,
string TeamSlug,
string ResourceName,
GoogleResourceType ResourceType,
string? Url);

/// <summary>
/// Service for linking and managing pre-shared Google resources for teams.
/// Unlike IGoogleSyncService (which provisions new resources), this service
/// validates and links existing resources that have been pre-shared with the service account.
///
/// This service is the sole owner of the <c>google_resources</c> table: callers must
/// never access <c>DbSet&lt;GoogleResource&gt;</c> directly and must go through the
/// read methods on this interface instead.
/// </summary>
public interface ITeamResourceService
{
/// <summary>
/// Gets all Google resources linked to a team.
/// Gets all active Google resources linked to a single team, ordered by provision time.
/// </summary>
Task<IReadOnlyList<GoogleResource>> GetTeamResourcesAsync(Guid teamId, CancellationToken ct = default);

/// <summary>
/// Gets all active Google resources for a set of teams, grouped by team id.
/// Missing team ids map to an empty list in the returned dictionary.
/// </summary>
Task<IReadOnlyDictionary<Guid, IReadOnlyList<GoogleResource>>> GetResourcesByTeamIdsAsync(
IReadOnlyCollection<Guid> teamIds,
CancellationToken ct = default);

/// <summary>
/// Gets aggregate summaries (mail group presence, drive resource count) for a set of teams.
/// Missing team ids map to <see cref="TeamResourceSummary.Empty"/>.
/// </summary>
Task<IReadOnlyDictionary<Guid, TeamResourceSummary>> GetTeamResourceSummariesAsync(
IReadOnlyCollection<Guid> teamIds,
CancellationToken ct = default);

/// <summary>
/// Returns the total active-resource count for every team that currently has any,
/// regardless of resource type. Used by admin aggregates (e.g. email rename impact).
/// </summary>
Task<IReadOnlyDictionary<Guid, int>> GetActiveResourceCountsByTeamAsync(CancellationToken ct = default);

/// <summary>
/// Gets the active Google resources visible to a user, joined with their team metadata.
/// Dashboard "My Google Resources" widget.
/// </summary>
Task<IReadOnlyList<UserTeamGoogleResource>> GetUserTeamResourcesAsync(Guid userId, CancellationToken ct = default);

/// <summary>
/// Gets every active Drive folder resource across all teams.
/// Used by Drive activity anomaly detection.
/// </summary>
Task<IReadOnlyList<GoogleResource>> GetActiveDriveFoldersAsync(CancellationToken ct = default);

/// <summary>
/// Returns the total number of Google resource rows (including inactive).
/// Used by the observable metrics gauge.
/// </summary>
Task<int> GetResourceCountAsync(CancellationToken ct = default);

/// <summary>
/// Links an existing Google Drive folder to a team by URL.
/// The folder must be pre-shared with the service account as Editor.
Expand Down
13 changes: 0 additions & 13 deletions src/Humans.Application/Interfaces/ITeamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,6 @@ public record MyTeamMembershipSummary(
bool CanLeave,
int PendingRequestCount);

public record UserTeamGoogleResource(
string TeamName,
string TeamSlug,
string ResourceName,
GoogleResourceType ResourceType,
string? Url);

public record TeamRosterSlotSummary(
string TeamName,
string TeamSlug,
Expand Down Expand Up @@ -171,12 +164,6 @@ Task<Team> CreateTeamAsync(
/// </summary>
Task<IReadOnlyList<TeamMember>> GetUserTeamsAsync(Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets active Google resources grouped by team for a user's active team memberships.
/// Used by the MyGoogleResources view component on the dashboard.
/// </summary>
Task<IReadOnlyList<UserTeamGoogleResource>> GetUserTeamGoogleResourcesAsync(Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the current user's team memberships with viewer-specific pending-request counts.
/// </summary>
Expand Down
5 changes: 0 additions & 5 deletions src/Humans.Domain/Entities/Team.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,6 @@ public class Team
/// </summary>
public ICollection<TeamJoinRequest> JoinRequests { get; } = new List<TeamJoinRequest>();

/// <summary>
/// Navigation property to associated Google resources.
/// </summary>
public ICollection<GoogleResource> GoogleResources { get; } = new List<GoogleResource>();

/// <summary>
/// Navigation property to legal documents scoped to this team.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,16 @@ public void Configure(EntityTypeBuilder<Team> builder)
.HasForeignKey(jr => jr.TeamId)
.OnDelete(DeleteBehavior.Cascade);

builder.HasMany(t => t.GoogleResources)
// google_resources is owned by TeamResourceService. The Team → GoogleResource
// navigation was removed to enforce ownership — the relationship is now
// configured from the GoogleResource side only via its Team nav property.
// Restrict (not SetNull): GoogleResource.TeamId is non-nullable, so SetNull
// would produce a NOT NULL violation on team delete. Teams should never be
// hard-deleted if resources exist — the caller must unlink resources first.
builder.HasMany<GoogleResource>()
.WithOne(gr => gr.Team)
.HasForeignKey(gr => gr.TeamId)
.OnDelete(DeleteBehavior.SetNull);
.OnDelete(DeleteBehavior.Restrict);

builder.HasIndex(t => t.Slug)
.IsUnique();
Expand Down
Loading
Loading