45 Commits

Author SHA1 Message Date
c40653b2b7 chore(ci): guards against tracked build artefacts
All checks were successful
deploy-socialize / deploy (push) Successful in 8s
deploy-socialize / image (push) Successful in 32s
2026-05-05 23:41:20 -04:00
f240d32ce6 chore(ci): use app Caddyfile in frontend image
All checks were successful
deploy-socialize / image (push) Successful in 55s
deploy-socialize / deploy (push) Successful in 8s
2026-05-05 23:37:25 -04:00
4775e35b3c Merge branch 'main' of sobina-git:jbourdon/social-media
All checks were successful
deploy-socialize / image (push) Successful in 2m7s
deploy-socialize / deploy (push) Successful in 32s
2026-05-05 23:26:59 -04:00
a7535d460d feat: refine content calendar experience 2026-05-05 23:25:58 -04:00
db344eebac chore(ci): remove CI test job
Some checks failed
deploy-socialize / image (push) Failing after 38s
deploy-socialize / deploy (push) Has been skipped
2026-05-05 23:13:12 -04:00
9699c4d55c chore(ci): split dotnet restore and test in CI
Some checks failed
deploy-socialize / test (push) Failing after 22s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 23:09:26 -04:00
c183626a7a chore(ci): use base64 encoded deploy SSH keys
Some checks failed
deploy-socialize / test (push) Failing after 22s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 23:04:12 -04:00
5db182dda9 chore(ci): use node runner image for checkout
Some checks failed
deploy-socialize / test (push) Failing after 17s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 23:01:24 -04:00
6296a91c3d chore(ci): run CI tests in isolated workspace
Some checks failed
deploy-socialize / test (push) Failing after 3s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 22:59:49 -04:00
91b7f96fdb chore(ci): run backend tests through dotnet SDK container
Some checks failed
deploy-socialize / test (push) Failing after 29s
deploy-socialize / deploy (push) Has been skipped
deploy-socialize / image (push) Has been skipped
2026-05-05 22:54:12 -04:00
88c4c23ce1 chore(ci): fix some dotnet compilation issue with glogging resx
Some checks failed
deploy-socialize / test (push) Failing after 1m33s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 22:50:42 -04:00
a96b3c897c chore(ci): fix gitea registry deploy workflow
Some checks failed
deploy-socialize / image (push) Has been skipped
deploy-socialize / test (push) Failing after 28s
deploy-socialize / deploy (push) Has been skipped
2026-05-05 22:41:12 -04:00
a437bfcfc3 chore(ci): update SEO
Some checks failed
deploy-socialize / test (push) Failing after 2s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 22:24:57 -04:00
b7b282a71a chore(ci): update configuration
Some checks failed
deploy-socialize / test (push) Failing after 3s
deploy-socialize / deploy (push) Has been skipped
deploy-socialize / image (push) Has been skipped
2026-05-05 22:23:19 -04:00
6083797eb1 add ci deployment workflow
Some checks failed
deploy-socialize / test (push) Failing after 48s
deploy-socialize / image (push) Has been skipped
deploy-socialize / deploy (push) Has been skipped
2026-05-05 21:54:16 -04:00
ecbd3daa1b Update frontend/Dockerfile
Some checks failed
Backend CI/CD / build_and_deploy (push) Waiting to run
Frontend CI/CD / build_and_deploy (push) Failing after 2m54s
2026-05-05 21:46:16 -04:00
b66c10b681 Add calendar integrations and collaboration updates
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-05 15:25:53 -04:00
c49f03ec06 chore: add script to easy recreating/reseeding the database
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-05 13:22:49 -04:00
23ae78f6e1 chore: hide some warnings about public/internal api 2026-05-05 13:21:48 -04:00
0d4188b64e Add multi-workspace selector scope 2026-05-05 13:20:44 -04:00
78a7517de7 feat: add alpha preview brand badge 2026-05-05 13:19:33 -04:00
244be555f9 Add real workspace channels 2026-05-05 13:06:57 -04:00
6e658b8215 docs: add calendar integration spec 2026-05-05 13:02:14 -04:00
f6c351c31e refactor: move public static pages
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-05 11:39:02 -04:00
5baacbceea fix: improve landing nav menus 2026-05-05 11:33:54 -04:00
feef8cbafd feat(copy): wrote some basic copy for the statis pages, landing, prices, products
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 22:08:42 -04:00
b7379cf823 feat: just getting better and better
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 21:34:38 -04:00
664eb07201 Polish workspace organization selector
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 17:44:39 -04:00
58c1301054 refactor: remove organization slug 2026-05-04 17:41:50 -04:00
552f4f1f21 fix: collapse sidebar by default on small screens 2026-05-04 16:40:43 -04:00
8f4b95f311 feat: add organization settings UI 2026-05-04 16:33:34 -04:00
4fba72e99c feat: prerender public site pages 2026-05-04 16:29:50 -04:00
55d8acef4c Refine content approval workflow rail 2026-05-04 16:20:32 -04:00
7d3f495472 feat: add organization domain foundation 2026-05-04 16:15:53 -04:00
802668fb0b feat: add public site pages and social login 2026-05-04 16:13:57 -04:00
cd6f402d9e docs: define organization account model 2026-05-04 15:45:12 -04:00
9bdef978bd refactor: align main layout shell 2026-05-04 14:46:13 -04:00
2d472892d6 Merge branch 'approval-workflow-docs' into HEAD
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
# Conflicts:
#	frontend/src/api/schema.d.ts
#	frontend/src/features/content/views/ContentItemDetailView.vue
#	frontend/src/features/workspaces/views/DashboardView.vue
#	shared/openapi/openapi.json
2026-05-01 15:58:04 -04:00
884ca4b96d chore: add missing multi-level editor for approval workflow, rename projects to campaings. 2026-05-01 15:50:02 -04:00
df0409d7f6 wip 2026-05-01 14:23:37 -04:00
5077f557f4 docs: redefine approval workflow 2026-05-01 00:58:47 -04:00
1722d65d22 chore(doc): remove unused edit-workspace-settings task
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 16:08:55 -04:00
14023e65d5 docs: remove platform-scaffold feature and tasks.
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:56:07 -04:00
237b1a4242 docs: adds workspace-invites feature and tasks
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:46:06 -04:00
ace0279bd0 fix(workspace-invite): inconsistence in roles names 2026-04-30 15:45:32 -04:00
290 changed files with 26994 additions and 8505 deletions

View File

@@ -0,0 +1,56 @@
name: deploy-socialize
on:
push:
branches:
- main
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check repository hygiene
run: ./scripts/check-repo-hygiene.sh
- name: Install Docker CLI
run: apt-get update && apt-get install -y docker.io
- name: Login to Gitea container registry
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: printf '%s' "$REGISTRY_PASSWORD" | docker login git.mapachotes.com -u "$REGISTRY_USER" --password-stdin
- name: Build images
run: |
docker build \
-t git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }} \
-t git.mapachotes.com/jbourdon/socialize-api:latest \
-f backend/src/Socialize.Api/Dockerfile .
docker build \
--build-arg VITE_API_URL=/api \
-t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \
-t git.mapachotes.com/jbourdon/socialize-web:latest \
-f frontend/Dockerfile .
- name: Push images
run: |
docker push git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }}
docker push git.mapachotes.com/jbourdon/socialize-api:latest
docker push git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }}
docker push git.mapachotes.com/jbourdon/socialize-web:latest
deploy:
needs: image
runs-on: bookworm
steps:
- name: Install SSH client
run: apt-get update && apt-get install -y openssh-client
- name: Deploy on sobina
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }}
run: |
mkdir -p ~/.ssh
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
'cd /srv/prod/socialize && ./deploy.sh'

View File

@@ -1,39 +0,0 @@
name: Backend CI/CD
on:
push:
branches:
- main
env:
AZURE_WEBAPP_NAME: hutopy-backend-api
DOTNET_VERSION: '10.0.x'
jobs:
build_and_deploy:
runs-on: ubuntu-latest
environment: dev
steps:
# Checkout the repository
- uses: actions/checkout@v2
# Setup .NET Core
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# Run dotnet publish
- name: dotnet build and publish
run: |
cd backend
dotnet publish --configuration Release --artifacts-path ./publish/ Socialize.slnx
# Deploy to Azure WebApp
- name: Deploy to Azure WebApp
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: './backend/publish/publish/Socialize.Api/release/'

View File

@@ -1,38 +0,0 @@
name: Frontend CI/CD
on:
push:
branches:
- main
env:
AZURE_SWA_NAME: hutopy-portal
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
# Checkout the repository
- uses: actions/checkout@v2
# Npm install
- name: npm install
run: |
cd frontend
npm install
# Npm run build
- name: npm run build
run: |
cd frontend
npm run build
# Deploy to Azure SWA
- name: Deploy to Azure SWA
uses: azure/static-web-apps-deploy@v1
with:
action: "upload"
app_location: 'frontend'
output_location: 'dist'
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}

4
.gitignore vendored
View File

@@ -22,6 +22,10 @@ Thumbs.db
# .NET # .NET
bin/ bin/
obj/ obj/
**/[Bb]in/
**/[Oo]bj/
**/[Bb]in[\\]*
**/[Oo]bj[\\]*
TestResults/ TestResults/
# Node # Node

View File

@@ -70,6 +70,7 @@ Update OpenAPI:
## Current Domain Modules ## Current Domain Modules
- `Identity`: authentication, refresh tokens, email verification, password reset, social login. - `Identity`: authentication, refresh tokens, email verification, password reset, social login.
- `Organizations`: SaaS account ownership, billing/subscription boundary, organization membership, connectors, data mappings, and owned workspaces.
- `Workspaces`: workspace membership, workspace settings, access scoping. - `Workspaces`: workspace membership, workspace settings, access scoping.
- `Clients`: client records and primary contacts tied to workspaces. - `Clients`: client records and primary contacts tied to workspaces.
- `Projects`: project pipeline and client/project relationships. - `Projects`: project pipeline and client/project relationships.

View File

@@ -1,6 +1,6 @@
# Socialize # Socialize
Socialize is a workspace-based workflow application for social media content review, revision, approval, and publication readiness. Socialize is an organization-owned, workspace-based workflow application for social media content review, revision, approval, and publication readiness.
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication. It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.

View File

@@ -1,82 +0,0 @@
name: Build
on:
pull_request:
branches: [ main ]
paths-ignore:
- '.scripts/**'
- .gitignore
- CODE_OF_CONDUCT.md
- LICENSE
- README.md
workflow_call:
inputs:
build-artifacts:
type: boolean
required: true
default: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
name: Checkout code
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Install .NET
uses: actions/setup-dotnet@v3
- name: Restore solution
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Test solution
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~AcceptanceTests"
- name: Publish website
if: ${{ inputs.build-artifacts == true }}
run: |
dotnet publish --configuration Release --runtime win-x86 --self-contained --output ./publish
cd publish
zip -r ./publish.zip .
working-directory: ./src/Web/
- name: Upload website artifact (website)
if: ${{ inputs.build-artifacts == true }}
uses: actions/upload-artifact@v3
with:
name: website
path: ./src/Web/publish/publish.zip
if-no-files-found: error
- name: Create EF Core migrations bundle
if: ${{ inputs.build-artifacts == true }}
run: |
dotnet new tool-manifest
dotnet tool install dotnet-ef
dotnet ef migrations bundle --configuration Release -p ./src/Infrastructure/ -s ./src/Web/ -o efbundle.exe
zip -r ./efbundle.zip efbundle.exe
env:
SkipNSwag: True
- name: Upload EF Core migrations bundle artifact (efbundle)
if: ${{ inputs.build-artifacts == true }}
uses: actions/upload-artifact@v3
with:
name: efbundle
path: ./efbundle.zip
if-no-files-found: error

View File

@@ -1,42 +0,0 @@
name: CICD
on:
push:
branches: [ main ]
paths-ignore:
- .gitignore
- CODE_OF_CONDUCT.md
- LICENSE
- README.md
permissions:
id-token: write
contents: read
jobs:
build:
uses: ./.github/workflows/build.yml
with:
build-artifacts: true
deploy-development:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ build ]
with:
environmentName: Development
deploy-staging:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ deploy-development ]
with:
environmentName: Staging
deploy-production:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ deploy-staging ]
with:
environmentName: Production

View File

@@ -1,107 +0,0 @@
name: Deploy
on:
workflow_call:
inputs:
environmentName:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
validate:
runs-on: ubuntu-latest
environment: ${{ inputs.environmentName }}
steps:
- uses: actions/checkout@v3
name: Checkout code
- uses: azure/login@v1
name: Login to Azure
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- if: inputs.environmentName == 'Development'
uses: azure/arm-deploy@v1
name: Run preflight validation
with:
deploymentName: ${{ github.run_number }}
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
deploymentMode: Validate
- if: inputs.environmentName != 'Development'
uses: azure/arm-deploy@v1
name: Run what-if
with:
failOnStdErr: false
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
additionalArguments: --what-if
deploy:
needs: [ validate ]
runs-on: ubuntu-latest
environment: ${{ inputs.environmentName }}
steps:
- uses: actions/checkout@v3
name: Checkout code
- uses: actions/download-artifact@v3
name: Download artifacts
- name: Install .NET
uses: actions/setup-dotnet@v3
- uses: azure/login@v1
name: Login to Azure
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: azure/arm-deploy@v1
id: deploy
name: Deploy infrastructure
with:
failOnStdErr: false
deploymentName: ${{ github.run_number }}
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
- name: Initialise database
run: |
unzip -o ./efbundle/efbundle.zip
echo '{ "ConnectionStrings": { "DefaultConnection": "" } }' > appsettings.json
./efbundle.exe --connection "Server=${{ steps.deploy.outputs.sqlServerFullyQualifiedDomainName }};Initial Catalog=${{ steps.deploy.outputs.sqlDatabaseName }};Persist Security Info=False;User ID=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }};Password=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" --verbose
- uses: azure/webapps-deploy@v2
name: Deploy website
with:
app-name: ${{ steps.deploy.outputs.appServiceAppName }}
package: website/publish.zip

View File

@@ -2,13 +2,16 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data; using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data; namespace Socialize.Api.Data;
@@ -17,36 +20,49 @@ public class AppDbContext(
DbContextOptions<AppDbContext> options) DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options) : IdentityDbContext<User, Role, Guid>(options)
{ {
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>(); public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>(); public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Client> Clients => Set<Client>(); public DbSet<Client> Clients => Set<Client>();
public DbSet<Project> Projects => Set<Project>(); public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>(); public DbSet<ContentItem> ContentItems => Set<ContentItem>();
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>(); public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
public DbSet<ContentItemActivityEntry> ContentItemActivityEntries => Set<ContentItemActivityEntry>();
public DbSet<Asset> Assets => Set<Asset>(); public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>(); public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>(); public DbSet<Comment> Comments => Set<Comment>();
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>(); public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>(); public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>(); public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>(); public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>(); public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>(); public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>(); public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>(); public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>();
public DbSet<CalendarSource> CalendarSources => Set<CalendarSource>();
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule(); builder.ConfigureWorkspacesModule();
builder.ConfigureChannelsModule();
builder.ConfigureClientsModule(); builder.ConfigureClientsModule();
builder.ConfigureProjectsModule(); builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule(); builder.ConfigureContentItemsModule();
builder.ConfigureAssetsModule(); builder.ConfigureAssetsModule();
builder.ConfigureCommentsModule(); builder.ConfigureCommentsModule();
builder.ConfigureApprovalsModule(); builder.ConfigureApprovalsModule();
builder.ConfigureNotificationsModule(); builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule(); builder.ConfigureFeedbackModule();
builder.ConfigureCalendarIntegrationsModule();
} }
} }

View File

@@ -4,6 +4,7 @@ internal static class ContainerNames
{ {
public const string Users = "users"; public const string Users = "users";
public const string Clients = "clients"; public const string Clients = "clients";
public const string Organizations = "organizations";
public const string Workspaces = "workspaces"; public const string Workspaces = "workspaces";
public const string Creators = "creators"; public const string Creators = "creators";
public const string Feedback = "feedback"; public const string Feedback = "feedback";

View File

@@ -6,11 +6,14 @@ using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data; using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -19,11 +22,16 @@ namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions public static class DevelopmentSeedExtensions
{ {
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-222222222222");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333"); private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333"); private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444"); private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid LumaInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000001");
private static readonly Guid LumaTikTokChannelId = Guid.Parse("33333333-3333-3333-3333-000000000002");
private static readonly Guid AtlasInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000003");
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555"); private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555"); private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
@@ -99,7 +107,7 @@ public static class DevelopmentSeedExtensions
[ [
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()), new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()), new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()), new Claim(KnownClaims.CampaignScope, ScopedCampaignId.ToString()),
]); ]);
User dev = await EnsureUserAsync( User dev = await EnsureUserAsync(
@@ -117,6 +125,12 @@ public static class DevelopmentSeedExtensions
[ [
]); ]);
await EnsureOrganizationDataAsync(
manager.Id,
dev.Id,
dbContext,
cancellationToken);
await EnsureWorkspaceDataAsync( await EnsureWorkspaceDataAsync(
manager.Id, manager.Id,
clientUser.Id, clientUser.Id,
@@ -200,7 +214,7 @@ public static class DevelopmentSeedExtensions
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user); IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
List<Claim> managedClaims = existingClaims List<Claim> managedClaims = existingClaims
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona) .Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.CampaignScope or KnownClaims.Persona)
.ToList(); .ToList();
foreach (Claim claim in managedClaims) foreach (Claim claim in managedClaims)
@@ -224,6 +238,75 @@ public static class DevelopmentSeedExtensions
return user; return user;
} }
private static async Task EnsureOrganizationDataAsync(
Guid managerUserId,
Guid developerUserId,
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken);
if (organization is null)
{
organization = new Organization
{
Id = OrganizationId,
Name = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Agency";
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000001"),
OrganizationId,
managerUserId,
OrganizationRoles.Owner,
cancellationToken);
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000002"),
OrganizationId,
developerUserId,
OrganizationRoles.Admin,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertOrganizationMembershipAsync(
AppDbContext dbContext,
Guid membershipId,
Guid organizationId,
Guid userId,
string role,
CancellationToken cancellationToken)
{
OrganizationMembership? membership = await dbContext.OrganizationMemberships
.SingleOrDefaultAsync(
candidate => candidate.OrganizationId == organizationId && candidate.UserId == userId,
cancellationToken);
if (membership is null)
{
membership = new OrganizationMembership
{
Id = membershipId,
OrganizationId = organizationId,
UserId = userId,
Role = role,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.OrganizationMemberships.Add(membership);
}
membership.Role = role;
}
private static async Task EnsureWorkspaceDataAsync( private static async Task EnsureWorkspaceDataAsync(
Guid managerUserId, Guid managerUserId,
Guid clientUserId, Guid clientUserId,
@@ -231,33 +314,31 @@ public static class DevelopmentSeedExtensions
AppDbContext dbContext, AppDbContext dbContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Workspace? workspace = await dbContext.Workspaces await UpsertWorkspaceAsync(
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken); dbContext,
if (workspace is null) WorkspaceId,
{ OrganizationId,
workspace = new Workspace managerUserId,
{ "Luma Coffee",
Id = WorkspaceId, "America/Montreal",
Name = string.Empty, "/images/seed/luma-coffee-logo.svg",
Slug = string.Empty, cancellationToken);
TimeZone = string.Empty, await UpsertWorkspaceAsync(
CreatedAt = DateTimeOffset.UtcNow, dbContext,
}; AtlasWorkspaceId,
dbContext.Workspaces.Add(workspace); OrganizationId,
} managerUserId,
"Atlas Bakery",
workspace.Name = "Northstar Studio"; "America/Montreal",
workspace.Slug = "northstar-studio"; "/images/seed/atlas-bakery-logo.svg",
workspace.OwnerUserId = managerUserId; cancellationToken);
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);
await UpsertClientAsync( await UpsertClientAsync(
dbContext, dbContext,
ScopedClientId, ScopedClientId,
"Luma Coffee", "Luma Coffee",
"Active", "Active",
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80", "/images/seed/luma-coffee-logo.svg",
"Sofia Martin", "Sofia Martin",
"client@socialize.local", "client@socialize.local",
WorkspaceId, WorkspaceId,
@@ -267,15 +348,15 @@ public static class DevelopmentSeedExtensions
HiddenClientId, HiddenClientId,
"Atlas Bakery", "Atlas Bakery",
"Active", "Active",
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80", "/images/seed/atlas-bakery-logo.svg",
"Nina Cole", "Nina Cole",
"nina@atlasbakery.test", "nina@atlasbakery.test",
WorkspaceId, AtlasWorkspaceId,
cancellationToken); cancellationToken);
await UpsertProjectAsync( await UpsertCampaignAsync(
dbContext, dbContext,
ScopedProjectId, ScopedCampaignId,
WorkspaceId, WorkspaceId,
ScopedClientId, ScopedClientId,
"Spring Launch", "Spring Launch",
@@ -285,10 +366,10 @@ public static class DevelopmentSeedExtensions
"Cross-channel launch campaign for the spring offer.", "Cross-channel launch campaign for the spring offer.",
"Coordinate creative approvals before the final week.", "Coordinate creative approvals before the final week.",
cancellationToken); cancellationToken);
await UpsertProjectAsync( await UpsertCampaignAsync(
dbContext, dbContext,
HiddenProjectId, HiddenCampaignId,
WorkspaceId, AtlasWorkspaceId,
HiddenClientId, HiddenClientId,
"Summer Retention", "Summer Retention",
"Planned", "Planned",
@@ -298,16 +379,44 @@ public static class DevelopmentSeedExtensions
"Sequence email and paid social updates together.", "Sequence email and paid social updates together.",
cancellationToken); cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaInstagramChannelId,
WorkspaceId,
"Luma Coffee Instagram",
"Instagram",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaTikTokChannelId,
WorkspaceId,
"Luma Coffee TikTok",
"TikTok",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
AtlasInstagramChannelId,
AtlasWorkspaceId,
"Atlas Bakery Instagram",
"Instagram",
"@atlasbakery",
null,
cancellationToken);
await UpsertContentItemAsync( await UpsertContentItemAsync(
dbContext, dbContext,
ScopedContentItemId, ScopedContentItemId,
WorkspaceId, WorkspaceId,
ScopedClientId, ScopedClientId,
ScopedProjectId, ScopedCampaignId,
"Spring launch hero video", "Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.", "Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok", "Luma Coffee Instagram, Luma Coffee TikTok",
"In client review", "In approval",
DateTimeOffset.UtcNow.AddDays(3), DateTimeOffset.UtcNow.AddDays(3),
"v3", "v3",
3, 3,
@@ -315,22 +424,22 @@ public static class DevelopmentSeedExtensions
await UpsertContentItemAsync( await UpsertContentItemAsync(
dbContext, dbContext,
HiddenContentItemId, HiddenContentItemId,
WorkspaceId, AtlasWorkspaceId,
HiddenClientId, HiddenClientId,
HiddenProjectId, HiddenCampaignId,
"Bakery loyalty carousel", "Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.", "Reward regular customers with a four-card retention carousel.",
"Instagram Carousel", "Atlas Bakery Instagram",
"Draft", "Draft",
DateTimeOffset.UtcNow.AddDays(10), DateTimeOffset.UtcNow.AddDays(10),
"v1", "v1",
1, 1,
cancellationToken); cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Luma Coffee Instagram, Luma Coffee TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Luma Coffee Instagram, Luma Coffee TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Luma Coffee Instagram, Luma Coffee TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken); Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
if (asset is null) if (asset is null)
@@ -378,8 +487,6 @@ public static class DevelopmentSeedExtensions
comment.AuthorDisplayName = "Sofia Martin"; comment.AuthorDisplayName = "Sofia Martin";
comment.AuthorEmail = "client@socialize.local"; comment.AuthorEmail = "client@socialize.local";
comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit."; comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit.";
comment.IsResolved = false;
comment.ResolvedAt = null;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken); ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
@@ -458,6 +565,38 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
private static async Task UpsertWorkspaceAsync(
AppDbContext dbContext,
Guid id,
Guid organizationId,
Guid ownerUserId,
string name,
string timeZone,
string logoUrl,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = id,
Name = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = name;
workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone;
workspace.LogoUrl = logoUrl;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertClientAsync( private static async Task UpsertClientAsync(
AppDbContext dbContext, AppDbContext dbContext,
Guid id, Guid id,
@@ -491,7 +630,7 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
private static async Task UpsertProjectAsync( private static async Task UpsertCampaignAsync(
AppDbContext dbContext, AppDbContext dbContext,
Guid id, Guid id,
Guid workspaceId, Guid workspaceId,
@@ -504,26 +643,57 @@ public static class DevelopmentSeedExtensions
string? notes, string? notes,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); Campaign? campaign = await dbContext.Campaigns.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (project is null) if (campaign is null)
{ {
project = new Project campaign = new Campaign
{ {
Id = id, Id = id,
Name = string.Empty, Name = string.Empty,
Status = string.Empty, Status = string.Empty,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
dbContext.Projects.Add(project); dbContext.Campaigns.Add(campaign);
} }
project.WorkspaceId = workspaceId; campaign.WorkspaceId = workspaceId;
project.ClientId = clientId; campaign.ClientId = clientId;
project.Name = name; campaign.Name = name;
project.Description = description; campaign.Description = description;
project.Notes = notes; campaign.Notes = notes;
project.Status = status; campaign.Status = status;
project.StartDate = startDate; campaign.StartDate = startDate;
project.EndDate = endDate; campaign.EndDate = endDate;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertChannelAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
string name,
string network,
string? handle,
string? externalUrl,
CancellationToken cancellationToken)
{
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (channel is null)
{
channel = new Channel
{
Id = id,
Name = string.Empty,
Network = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
}
channel.WorkspaceId = workspaceId;
channel.Name = name;
channel.Network = network;
channel.Handle = handle;
channel.ExternalUrl = externalUrl;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
@@ -532,7 +702,7 @@ public static class DevelopmentSeedExtensions
Guid id, Guid id,
Guid workspaceId, Guid workspaceId,
Guid clientId, Guid clientId,
Guid projectId, Guid campaignId,
string title, string title,
string publicationMessage, string publicationMessage,
string publicationTargets, string publicationTargets,
@@ -559,7 +729,7 @@ public static class DevelopmentSeedExtensions
} }
item.WorkspaceId = workspaceId; item.WorkspaceId = workspaceId;
item.ClientId = clientId; item.ClientId = clientId;
item.ProjectId = projectId; item.CampaignId = campaignId;
item.Title = title; item.Title = title;
item.PublicationMessage = publicationMessage; item.PublicationMessage = publicationMessage;
item.PublicationTargets = publicationTargets; item.PublicationTargets = publicationTargets;

View File

@@ -1,9 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService public sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{ {
public bool IsManager(ClaimsPrincipal user) public bool IsManager(ClaimsPrincipal user)
{ {
@@ -36,21 +38,140 @@ public sealed class AccessScopeService
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); || (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
} }
public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId)); || (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
} }
public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)); return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
} }
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) return IsManager(user)
|| IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId) || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId); || IsClient(user) && CanAccessClient(user, workspaceId, clientId);
} }
public Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct);
}
public async Task<bool> CanAccessWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct);
}
public async Task<bool> CanManageWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct);
}
public async Task<bool> CanCreateWorkspaceAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
ct);
}
public async Task<bool> CanAccessClientAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId);
}
public async Task<bool> CanAccessCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return await CanAccessClientAsync(user, workspaceId, clientId, ct) &&
user.GetCampaignScopeIds().Contains(campaignId);
}
public async Task<bool> CanContributeToCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
}
public async Task<bool> CanReviewContentAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
}
} }

View File

@@ -23,9 +23,9 @@ public static class ClaimsPrincipalExtensions
return claims.GetScopeIds(KnownClaims.ClientScope); return claims.GetScopeIds(KnownClaims.ClientScope);
} }
public static IReadOnlyCollection<Guid> GetProjectScopeIds(this ClaimsPrincipal claims) public static IReadOnlyCollection<Guid> GetCampaignScopeIds(this ClaimsPrincipal claims)
{ {
return claims.GetScopeIds(KnownClaims.ProjectScope); return claims.GetScopeIds(KnownClaims.CampaignScope);
} }
public static string? GetPersona(this ClaimsPrincipal claims) public static string? GetPersona(this ClaimsPrincipal claims)

View File

@@ -6,6 +6,6 @@ public static class KnownClaims
public const string PortraitUrl = "portraitUrl"; public const string PortraitUrl = "portraitUrl";
public const string WorkspaceScope = "workspace"; public const string WorkspaceScope = "workspace";
public const string ClientScope = "client"; public const string ClientScope = "client";
public const string ProjectScope = "project"; public const string CampaignScope = "campaign";
public const string Persona = "persona"; public const string Persona = "persona";
} }

View File

@@ -1,942 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260423061407_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApprovalRequestId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DecidedByEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("DecidedByName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("DecidedByUserId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ApprovalRequestId");
b.ToTable("ApprovalDecisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DueAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RequestedByUserId")
.HasColumnType("uuid");
b.Property<string>("ReviewerEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReviewerName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveFileId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveLink")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("SourceReference")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("AssetId", "RevisionNumber")
.IsUnique();
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PrimaryContactEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactPortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Name")
.IsUnique();
b.ToTable("Clients", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("AuthorUserId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ParentCommentId");
b.HasIndex("WorkspaceId");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CurrentRevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ChangeSummary")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ContentItemId", "RevisionNumber")
.IsUnique();
b.ToTable("ContentItemRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Alias")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FacebookId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Firstname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Lastname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RefreshToken")
.HasMaxLength(44)
.HasColumnType("character varying(44)");
b.Property<DateTime>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("MetadataJson")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecipientEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("RecipientUserId")
.HasColumnType("uuid");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientUserId");
b.HasIndex("WorkspaceId");
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Email", "Status");
b.ToTable("WorkspaceInvites", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,657 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApprovalDecisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ApprovalRequestId = table.Column<Guid>(type: "uuid", nullable: false),
Decision = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Comment = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DecidedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
DecidedByName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
DecidedByEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalDecisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ApprovalRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
Stage = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ReviewerName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReviewerEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
RequestedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
DueAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AccessToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Alias = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Firstname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Lastname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
BirthDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
GoogleId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
FacebookId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
RefreshToken = table.Column<string>(type: "character varying(44)", maxLength: 44, nullable: true),
RefreshTokenExpiryTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AssetRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
SourceReference = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Notes = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_AssetRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Assets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
AssetType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SourceType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
GoogleDriveFileId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
GoogleDriveLink = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Assets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Clients",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PrimaryContactName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PrimaryContactEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PrimaryContactPortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Clients", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Comments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
ParentCommentId = table.Column<Guid>(type: "uuid", nullable: true),
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
IsResolved = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Comments", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContentItemRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
RevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ChangeSummary = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ContentItemRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContentItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DueDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CurrentRevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ContentItems", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
EventType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
EntityType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
RecipientUserId = table.Column<Guid>(type: "uuid", nullable: true),
RecipientEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
MetadataJson = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ReadAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationEvents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WorkspaceInvites",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_WorkspaceInvites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Workspaces",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
TimeZone = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Workspaces", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalDecisions_ApprovalRequestId",
table: "ApprovalDecisions",
column: "ApprovalRequestId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_ContentItemId",
table: "ApprovalRequests",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_ReviewerEmail",
table: "ApprovalRequests",
column: "ReviewerEmail");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkspaceId",
table: "ApprovalRequests",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AssetRevisions_AssetId",
table: "AssetRevisions",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_AssetRevisions_AssetId_RevisionNumber",
table: "AssetRevisions",
columns: new[] { "AssetId", "RevisionNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Assets_ContentItemId",
table: "Assets",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_Assets_WorkspaceId",
table: "Assets",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Clients_WorkspaceId",
table: "Clients",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Clients_WorkspaceId_Name",
table: "Clients",
columns: new[] { "WorkspaceId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Comments_ContentItemId",
table: "Comments",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_Comments_ParentCommentId",
table: "Comments",
column: "ParentCommentId");
migrationBuilder.CreateIndex(
name: "IX_Comments_WorkspaceId",
table: "Comments",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_ContentItemRevisions_ContentItemId",
table: "ContentItemRevisions",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ContentItemRevisions_ContentItemId_RevisionNumber",
table: "ContentItemRevisions",
columns: new[] { "ContentItemId", "RevisionNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ClientId",
table: "ContentItems",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ProjectId",
table: "ContentItems",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_WorkspaceId",
table: "ContentItems",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_ContentItemId",
table: "NotificationEvents",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_CreatedAt",
table: "NotificationEvents",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_RecipientUserId",
table: "NotificationEvents",
column: "RecipientUserId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_WorkspaceId",
table: "NotificationEvents",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId",
table: "Projects",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId_Name",
table: "Projects",
columns: new[] { "ClientId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Projects_WorkspaceId",
table: "Projects",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceInvites_WorkspaceId",
table: "WorkspaceInvites",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceInvites_WorkspaceId_Email_Status",
table: "WorkspaceInvites",
columns: new[] { "WorkspaceId", "Email", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OwnerUserId",
table: "Workspaces",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_Slug",
table: "Workspaces",
column: "Slug",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalDecisions");
migrationBuilder.DropTable(
name: "ApprovalRequests");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AssetRevisions");
migrationBuilder.DropTable(
name: "Assets");
migrationBuilder.DropTable(
name: "Clients");
migrationBuilder.DropTable(
name: "Comments");
migrationBuilder.DropTable(
name: "ContentItemRevisions");
migrationBuilder.DropTable(
name: "ContentItems");
migrationBuilder.DropTable(
name: "NotificationEvents");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropTable(
name: "WorkspaceInvites");
migrationBuilder.DropTable(
name: "Workspaces");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260430054500_AddWorkspaceLogo")]
public partial class AddWorkspaceLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LogoUrl",
table: "Workspaces",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogoUrl",
table: "Workspaces");
}
}
}

View File

@@ -1,116 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackReports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FeedbackTags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_LastActivityAt",
table: "FeedbackReports",
column: "LastActivityAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ReporterUserId",
table: "FeedbackReports",
column: "ReporterUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Status",
table: "FeedbackReports",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Type",
table: "FeedbackReports",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_WorkspaceId",
table: "FeedbackReports",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
table: "FeedbackTags",
columns: new[] { "FeedbackReportId", "NormalizedName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_NormalizedName",
table: "FeedbackTags",
column: "NormalizedName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackTags");
migrationBuilder.DropTable(
name: "FeedbackReports");
}
}
}

View File

@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackScreenshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackScreenshots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackScreenshots_FeedbackReportId",
table: "FeedbackScreenshots",
column: "FeedbackReportId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackScreenshots");
}
}
}

View File

@@ -1,105 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackCommentsActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackActivityEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
ActorUserId = table.Column<Guid>(type: "uuid", nullable: false),
ActorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActivityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
FromValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
ToValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Note = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackComments", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_ActorUserId",
table: "FeedbackActivityEntries",
column: "ActorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_CreatedAt",
table: "FeedbackActivityEntries",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_FeedbackReportId",
table: "FeedbackActivityEntries",
column: "FeedbackReportId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_AuthorUserId",
table: "FeedbackComments",
column: "AuthorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_CreatedAt",
table: "FeedbackComments",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_FeedbackReportId",
table: "FeedbackComments",
column: "FeedbackReportId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackActivityEntries");
migrationBuilder.DropTable(
name: "FeedbackComments");
}
}
}

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations namespace Socialize.Api.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260430171959_AddFeedbackCommentsActivity")] [Migration("20260505204545_Initial")]
partial class AddFeedbackCommentsActivity partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -219,6 +219,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)"); .HasColumnType("character varying(64)");
b.Property<Guid?>("WorkflowInstanceId")
.HasColumnType("uuid");
b.Property<int?>("WorkflowStepRequiredApproverCount")
.HasColumnType("integer");
b.Property<int?>("WorkflowStepSortOrder")
.HasColumnType("integer");
b.Property<string>("WorkflowStepTargetType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("WorkflowStepTargetValue")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId") b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -228,11 +245,103 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail"); b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId"); b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null); b.ToTable("ApprovalRequests", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "State")
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
b.ToTable("ApprovalWorkflowInstances", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("RequiredApproverCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetValue")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "SortOrder")
.IsUnique();
b.ToTable("WorkspaceApprovalStepConfigurations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -332,6 +441,421 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null); b.ToTable("AssetRevisions", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Country")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CultureOrReligion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DefaultColor")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Region")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("TrustLevel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Category");
b.HasIndex("Country");
b.HasIndex("ProviderName");
b.ToTable("CalendarCatalogEntries", (string)null);
b.HasData(
new
{
Id = new Guid("10000000-0000-0000-0000-000000000001"),
Category = "public-holiday",
Country = "US",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#2F80ED",
Description = "Federal public holiday calendar for the United States.",
Language = "en",
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US",
Title = "United States Public Holidays",
TrustLevel = "Verified"
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000002"),
Category = "public-holiday",
Country = "CA",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#2F80ED",
Description = "Public holiday calendar for Canada.",
Language = "en",
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA",
Title = "Canada Public Holidays",
TrustLevel = "Verified"
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000003"),
Category = "marketing-moment",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#9B51E0",
Description = "Common retail, awareness, and social planning moments.",
Language = "en",
ProviderName = "Socialize",
SourceUrl = "https://example.com/socialize/marketing-moments.ics",
Title = "Common Marketing Moments",
TrustLevel = "Maintained"
});
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CalendarSourceId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<DateTime?>("EndLocalDateTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ImportedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsAllDay")
.HasColumnType("boolean");
b.Property<bool>("IsFloatingTime")
.HasColumnType("boolean");
b.Property<string>("Location")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RecurrenceId")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("SourceEventUid")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset?>("SourceLastModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<DateTime?>("StartLocalDateTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("StartUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("TimeZoneId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("CalendarSourceId");
b.HasIndex("CalendarSourceId", "SourceEventUid", "StartDate")
.IsUnique();
b.ToTable("CalendarEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CatalogSourceReference")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DisplayTitle")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("InheritanceMode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastAttemptedSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastSuccessfulSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastSyncError")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SourceUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Scope");
b.HasIndex("UserId");
b.HasIndex("WorkspaceId");
b.ToTable("CalendarSources", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.UserCalendarExportFeed", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId")
.IsUnique();
b.ToTable("UserCalendarExportFeeds", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Campaigns", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Handle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Network")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Network", "Name")
.IsUnique();
b.ToTable("Channels", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -388,6 +912,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AttachmentBlobContainerName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentBlobName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("AttachmentBlobUrl")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("AttachmentContentType")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentFileName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long?>("AttachmentSizeBytes")
.HasColumnType("bigint");
b.Property<string>("AuthorDisplayName") b.Property<string>("AuthorDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -414,15 +961,9 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId") b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId") b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -443,6 +984,9 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid");
b.Property<Guid>("ClientId") b.Property<Guid>("ClientId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -466,9 +1010,6 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)"); .HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage") b.Property<string>("PublicationMessage")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasMaxLength(4000)
@@ -494,15 +1035,71 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ClientId"); b.HasIndex("CampaignId");
b.HasIndex("ProjectId"); b.HasIndex("ClientId");
b.HasIndex("WorkspaceId"); b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null); b.ToTable("ContentItems", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "CreatedAt");
b.ToTable("ContentItemActivityEntries", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -678,6 +1275,13 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)"); .HasColumnType("character varying(1024)");
b.Property<Guid?>("CampaignId")
.HasColumnType("uuid");
b.Property<string>("CampaignName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CancellationReason") b.Property<string>("CancellationReason")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
@@ -715,13 +1319,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset>("LastActivityAt") b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName") b.Property<string>("ReporterDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -1044,60 +1641,7 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null); b.ToTable("NotificationEvents", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b => modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -1120,10 +1664,94 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("OwnerUserId") b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Slug") b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Organizations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(64)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
@@ -1132,10 +1760,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerUserId"); b.HasIndex("OrganizationId");
b.HasIndex("Slug") b.HasIndex("OwnerUserId");
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });
@@ -1232,6 +1859,15 @@ namespace Socialize.Api.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b =>
{
b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null)
.WithMany()
.HasForeignKey("CalendarSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{ {
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1276,6 +1912,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport"); b.Navigation("FeedbackReport");
}); });
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{ {
b.Navigation("ActivityEntries"); b.Navigation("ActivityEntries");

File diff suppressed because it is too large Load Diff

View File

@@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)"); .HasColumnType("character varying(64)");
b.Property<Guid?>("WorkflowInstanceId")
.HasColumnType("uuid");
b.Property<int?>("WorkflowStepRequiredApproverCount")
.HasColumnType("integer");
b.Property<int?>("WorkflowStepSortOrder")
.HasColumnType("integer");
b.Property<string>("WorkflowStepTargetType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("WorkflowStepTargetValue")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId") b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -225,11 +242,103 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail"); b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId"); b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null); b.ToTable("ApprovalRequests", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "State")
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
b.ToTable("ApprovalWorkflowInstances", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("RequiredApproverCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetValue")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "SortOrder")
.IsUnique();
b.ToTable("WorkspaceApprovalStepConfigurations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -329,6 +438,421 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null); b.ToTable("AssetRevisions", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Country")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CultureOrReligion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DefaultColor")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Region")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("TrustLevel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Category");
b.HasIndex("Country");
b.HasIndex("ProviderName");
b.ToTable("CalendarCatalogEntries", (string)null);
b.HasData(
new
{
Id = new Guid("10000000-0000-0000-0000-000000000001"),
Category = "public-holiday",
Country = "US",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#2F80ED",
Description = "Federal public holiday calendar for the United States.",
Language = "en",
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US",
Title = "United States Public Holidays",
TrustLevel = "Verified"
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000002"),
Category = "public-holiday",
Country = "CA",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#2F80ED",
Description = "Public holiday calendar for Canada.",
Language = "en",
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA",
Title = "Canada Public Holidays",
TrustLevel = "Verified"
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000003"),
Category = "marketing-moment",
CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DefaultColor = "#9B51E0",
Description = "Common retail, awareness, and social planning moments.",
Language = "en",
ProviderName = "Socialize",
SourceUrl = "https://example.com/socialize/marketing-moments.ics",
Title = "Common Marketing Moments",
TrustLevel = "Maintained"
});
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CalendarSourceId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<DateTime?>("EndLocalDateTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ImportedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsAllDay")
.HasColumnType("boolean");
b.Property<bool>("IsFloatingTime")
.HasColumnType("boolean");
b.Property<string>("Location")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RecurrenceId")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("SourceEventUid")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset?>("SourceLastModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<DateTime?>("StartLocalDateTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("StartUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("TimeZoneId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("CalendarSourceId");
b.HasIndex("CalendarSourceId", "SourceEventUid", "StartDate")
.IsUnique();
b.ToTable("CalendarEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CatalogSourceReference")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DisplayTitle")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("InheritanceMode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastAttemptedSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastSuccessfulSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastSyncError")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SourceUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Scope");
b.HasIndex("UserId");
b.HasIndex("WorkspaceId");
b.ToTable("CalendarSources", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.UserCalendarExportFeed", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId")
.IsUnique();
b.ToTable("UserCalendarExportFeeds", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Campaigns", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Handle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Network")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Network", "Name")
.IsUnique();
b.ToTable("Channels", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -385,6 +909,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AttachmentBlobContainerName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentBlobName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("AttachmentBlobUrl")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("AttachmentContentType")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentFileName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long?>("AttachmentSizeBytes")
.HasColumnType("bigint");
b.Property<string>("AuthorDisplayName") b.Property<string>("AuthorDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -411,15 +958,9 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId") b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId") b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -440,6 +981,9 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid");
b.Property<Guid>("ClientId") b.Property<Guid>("ClientId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -463,9 +1007,6 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)"); .HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage") b.Property<string>("PublicationMessage")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasMaxLength(4000)
@@ -491,15 +1032,71 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ClientId"); b.HasIndex("CampaignId");
b.HasIndex("ProjectId"); b.HasIndex("ClientId");
b.HasIndex("WorkspaceId"); b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null); b.ToTable("ContentItems", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "CreatedAt");
b.ToTable("ContentItemActivityEntries", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -675,6 +1272,13 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)"); .HasColumnType("character varying(1024)");
b.Property<Guid?>("CampaignId")
.HasColumnType("uuid");
b.Property<string>("CampaignName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CancellationReason") b.Property<string>("CancellationReason")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
@@ -712,13 +1316,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset>("LastActivityAt") b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName") b.Property<string>("ReporterDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -1041,60 +1638,7 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null); b.ToTable("NotificationEvents", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b => modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -1117,10 +1661,94 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("OwnerUserId") b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Slug") b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Organizations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(64)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
@@ -1129,10 +1757,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerUserId"); b.HasIndex("OrganizationId");
b.HasIndex("Slug") b.HasIndex("OwnerUserId");
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });
@@ -1229,6 +1856,15 @@ namespace Socialize.Api.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b =>
{
b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null)
.WithMany()
.HasForeignKey("CalendarSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{ {
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1273,6 +1909,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport"); b.Navigation("FeedbackReport");
}); });
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{ {
b.Navigation("ActivityEntries"); b.Navigation("ActivityEntries");

View File

@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
{ {
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<ApprovalWorkflowInstance>(workflowInstance =>
{
workflowInstance.ToTable("ApprovalWorkflowInstances");
workflowInstance.HasKey(x => x.Id);
workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.StartedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workflowInstance.HasIndex(x => x.WorkspaceId);
workflowInstance.HasIndex(x => x.ContentItemId);
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
});
modelBuilder.Entity<ApprovalRequest>(approvalRequest => modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
{ {
approvalRequest.ToTable("ApprovalRequests"); approvalRequest.ToTable("ApprovalRequests");
approvalRequest.HasKey(x => x.Id); approvalRequest.HasKey(x => x.Id);
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired(); approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired(); approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired(); approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalRequest.HasIndex(x => x.WorkspaceId); approvalRequest.HasIndex(x => x.WorkspaceId);
approvalRequest.HasIndex(x => x.ContentItemId); approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
approvalRequest.HasIndex(x => x.ReviewerEmail); approvalRequest.HasIndex(x => x.ReviewerEmail);
}); });
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
approvalDecision.HasIndex(x => x.ApprovalRequestId); approvalDecision.HasIndex(x => x.ApprovalRequestId);
}); });
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
{
approvalStep.ToTable("WorkspaceApprovalStepConfigurations");
approvalStep.HasKey(x => x.Id);
approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired();
approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1);
approvalStep.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalStep.HasIndex(x => x.WorkspaceId);
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
});
return modelBuilder; return modelBuilder;
} }
} }

View File

@@ -5,6 +5,11 @@ public class ApprovalRequest
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; } public Guid ContentItemId { get; set; }
public Guid? WorkflowInstanceId { get; set; }
public int? WorkflowStepSortOrder { get; set; }
public string? WorkflowStepTargetType { get; set; }
public string? WorkflowStepTargetValue { get; set; }
public int? WorkflowStepRequiredApproverCount { get; set; }
public required string Stage { get; set; } public required string Stage { get; set; }
public required string ReviewerName { get; set; } public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; } public required string ReviewerEmail { get; set; }

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalWorkflowInstance
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string State { get; set; }
public required string ApprovalMode { get; set; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class WorkspaceApprovalStepConfiguration
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public int SortOrder { get; set; }
public required string TargetType { get; set; }
public required string TargetValue { get; set; }
public int RequiredApproverCount { get; set; } = 1;
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -1,4 +1,4 @@
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Api.Modules.Approvals; namespace Socialize.Api.Modules.Approvals;
@@ -7,6 +7,8 @@ public static class DependencyInjection
public static WebApplicationBuilder AddApprovalsModule( public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder) this WebApplicationBuilder builder)
{ {
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
return builder; return builder;
} }
} }

View File

@@ -1,123 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
DateTimeOffset? DueAt);
public class CreateApprovalRequestRequestValidator
: Validator<CreateApprovalRequestRequest>
{
public CreateApprovalRequestRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
}
}
public class CreateApprovalRequestHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
var contentItem = await dbContext
.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
var approval = new ApprovalRequest()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
Stage = request.Stage.Trim(),
ReviewerName = request.ReviewerName.Trim(),
ReviewerEmail = request.ReviewerEmail.Trim(),
RequestedByUserId = User.GetUserId(),
DueAt = request.DueAt,
State = "Pending",
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
SentAt = DateTimeOffset.UtcNow,
};
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.requested",
"ApprovalRequest",
approval.Id,
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
null,
approval.ReviewerEmail,
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
ct);
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
[]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
Guid? WorkflowInstanceId,
int? WorkflowStepSortOrder,
string? WorkflowStepTargetType,
string? WorkflowStepTargetValue,
int? WorkflowStepRequiredApproverCount,
string Stage, string Stage,
string ReviewerName, string ReviewerName,
string ReviewerEmail, string ReviewerEmail,
@@ -56,7 +61,7 @@ public class GetApprovalsHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId) .Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt) .OrderByDescending(approval => approval.SentAt)
.ThenBy(approval => approval.WorkflowStepSortOrder)
.ToListAsync(ct); .ToListAsync(ct);
List<Guid> approvalIds = approvals List<Guid> approvalIds = approvals
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
approval.Id, approval.Id,
approval.WorkspaceId, approval.WorkspaceId,
approval.ContentItemId, approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage, approval.Stage,
approval.ReviewerName, approval.ReviewerName,
approval.ReviewerEmail, approval.ReviewerEmail,

View File

@@ -3,14 +3,17 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest( public record SubmitApprovalDecisionRequest(
string Decision, string Decision,
string? Comment,
string? ReviewerName, string? ReviewerName,
string? ReviewerEmail); string? ReviewerEmail);
@@ -19,8 +22,10 @@ public class SubmitApprovalDecisionRequestValidator
{ {
public SubmitApprovalDecisionRequestValidator() public SubmitApprovalDecisionRequestValidator()
{ {
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64); RuleFor(x => x.Decision)
RuleFor(x => x.Comment).MaximumLength(2048); .NotEmpty()
.Equal("Approved")
.WithMessage("Only approved decisions are supported.");
RuleFor(x => x.ReviewerName).MaximumLength(256); RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail)); RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
} }
@@ -29,6 +34,8 @@ public class SubmitApprovalDecisionRequestValidator
public class SubmitApprovalDecisionHandler( public class SubmitApprovalDecisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto> : Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{ {
@@ -58,12 +65,19 @@ public class SubmitApprovalDecisionHandler(
} }
if (User?.Identity?.IsAuthenticated == true && if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) !await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
} }
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim(); string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName() ? User.GetAlias() ?? User.GetName()
@@ -77,52 +91,69 @@ public class SubmitApprovalDecisionHandler(
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ApprovalRequestId = approval.Id, ApprovalRequestId = approval.Id,
Decision = normalizedDecision, Decision = normalizedDecision,
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(), Comment = null,
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null, DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName, DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail, DecidedByEmail = decidedByEmail,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
approval.State = normalizedDecision; ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
approval.CompletedAt = DateTimeOffset.UtcNow; .ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
if (approval.Stage == "Internal") if (!workflowDecisionResult.Succeeded)
{ {
contentItem.Status = normalizedDecision switch AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
{ await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
"Approved" => "Ready for client review", return;
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
} }
dbContext.ApprovalDecisions.Add(decision); if (!workflowDecisionResult.IsWorkflowStep)
await dbContext.SaveChangesAsync(ct); {
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
await notificationEventWriter.WriteAsync( if (normalizedDecision == "Approved")
new NotificationEventWriteModel( {
approval.WorkspaceId, contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
approval.ContentItemId, workspace.SchedulePostsAutomaticallyOnApproval,
"approval.decision.recorded", contentItem.DueDate);
"ApprovalDecision", }
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.", dbContext.ApprovalDecisions.Add(decision);
null, await dbContext.SaveChangesAsync(ct);
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""), await activityWriter.WriteAsync(
ct); new ContentItemActivityWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
decision.DecidedByUserId,
decidedByEmail,
JsonSerializer.Serialize(new
{
stage = approval.Stage,
status = contentItem.Status,
decision = normalizedDecision,
})),
ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
null,
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
}
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id) .Where(candidate => candidate.ApprovalRequestId == approval.Id)
@@ -158,6 +189,11 @@ public class SubmitApprovalDecisionHandler(
approval.Id, approval.Id,
approval.WorkspaceId, approval.WorkspaceId,
approval.ContentItemId, approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage, approval.Stage,
approval.ReviewerName, approval.ReviewerName,
approval.ReviewerEmail, approval.ReviewerEmail,

View File

@@ -0,0 +1,56 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalStepTargetTypes
{
public const string Role = "Role";
public const string Membership = "Membership";
public const string Member = "Member";
}
public static class ApprovalMembershipTargets
{
public const string Team = "Team";
public const string Client = "Client";
}
public static class ApprovalStepConfigurationRules
{
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalStepTargetTypes.Role,
ApprovalStepTargetTypes.Membership,
ApprovalStepTargetTypes.Member,
};
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
{
KnownRoles.Administrator,
KnownRoles.Manager,
KnownRoles.WorkspaceMember,
KnownRoles.Client,
KnownRoles.Provider,
};
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalMembershipTargets.Team,
ApprovalMembershipTargets.Client,
};
public static bool IsValidTargetType(string? targetType)
{
return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim());
}
public static bool IsValidRoleTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim());
}
public static bool IsValidMembershipTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim());
}
}

View File

@@ -0,0 +1,97 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalModes
{
public const string None = "None";
public const string Optional = "Optional";
public const string Required = "Required";
public const string MultiLevel = "Multi-level";
}
public static class ApprovalWorkflowRules
{
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
{
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
}
public static bool IsApprovalCompletionStatus(string status)
{
return status is "Approved" or "Scheduled";
}
public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate)
{
return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue
? "Scheduled"
: "Approved";
}
public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount)
{
return approvedDecisionCount >= Math.Max(1, requiredApproverCount);
}
public static bool CanApproveWorkflowStep(
bool isAdministrator,
bool hasWorkspaceAccess,
IReadOnlyCollection<string> userRoles,
Guid userId,
string? targetType,
string? targetValue)
{
if (isAdministrator)
{
return true;
}
if (!hasWorkspaceAccess ||
string.IsNullOrWhiteSpace(targetType) ||
string.IsNullOrWhiteSpace(targetValue))
{
return false;
}
return targetType switch
{
ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue),
ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue),
ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId),
_ => false,
};
}
public static IReadOnlyCollection<Guid> ParseMemberTargetIds(string? targetValue)
{
if (string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetValue
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty)
.Where(memberUserId => memberUserId != Guid.Empty)
.Distinct()
.ToArray();
}
public static string FormatMemberTargetValue(IEnumerable<Guid> memberUserIds)
{
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
}
private static bool MatchesMembershipTarget(
IReadOnlyCollection<string> userRoles,
string targetValue)
{
return targetValue switch
{
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
_ => false,
};
}
}

View File

@@ -0,0 +1,401 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Services;
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
public record ApprovalWorkflowDecisionResult(
bool Succeeded,
string? ErrorMessage,
int StatusCode,
bool IsWorkflowStep);
public class ApprovalWorkflowRuntimeService(
AppDbContext dbContext,
INotificationEventWriter notificationEventWriter)
{
private const string PendingState = "Pending";
private const string ApprovedState = "Approved";
public async Task<ApprovalWorkflowStartResult> StartMultiLevelWorkflowAsync(
ContentItem contentItem,
Workspace workspace,
Guid requestedByUserId,
CancellationToken ct)
{
if (workspace.ApprovalMode != ApprovalModes.MultiLevel)
{
return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval.");
}
ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(
workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState,
ct);
if (activeWorkflow is not null)
{
contentItem.Status = "In approval";
return new ApprovalWorkflowStartResult(true, null);
}
List<WorkspaceApprovalStepConfiguration> configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.ToListAsync(ct);
if (configuredSteps.Count == 0)
{
return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step.");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var workflowInstance = new ApprovalWorkflowInstance
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
State = PendingState,
ApprovalMode = workspace.ApprovalMode,
StartedAt = now,
};
List<ApprovalRequest> workflowSteps = configuredSteps
.Select((step, index) => new ApprovalRequest
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
WorkflowInstanceId = workflowInstance.Id,
WorkflowStepSortOrder = index,
WorkflowStepTargetType = step.TargetType,
WorkflowStepTargetValue = step.TargetValue,
WorkflowStepRequiredApproverCount = step.RequiredApproverCount,
Stage = step.Name,
ReviewerName = FormatStepTarget(step),
ReviewerEmail = string.Empty,
RequestedByUserId = requestedByUserId,
DueAt = contentItem.DueDate,
State = PendingState,
AccessToken = CreateAccessToken(),
SentAt = now,
})
.ToList();
dbContext.ApprovalWorkflowInstances.Add(workflowInstance);
dbContext.ApprovalRequests.AddRange(workflowSteps);
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct);
return new ApprovalWorkflowStartResult(true, null);
}
public async Task<ApprovalWorkflowDecisionResult> ApplyWorkflowStepDecisionAsync(
ApprovalRequest approval,
ContentItem contentItem,
Workspace workspace,
ClaimsPrincipal user,
ApprovalDecision decision,
CancellationToken ct)
{
if (!approval.WorkflowInstanceId.HasValue)
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false);
}
if (user.Identity?.IsAuthenticated != true)
{
return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true);
}
if (!await CanApproveStepAsync(user, approval, workspace.Id, ct))
{
return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true);
}
ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct);
if (currentStep?.Id != approval.Id)
{
return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true);
}
Guid currentUserId = user.GetUserId();
bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync(
candidate => candidate.ApprovalRequestId == approval.Id &&
candidate.DecidedByUserId == currentUserId &&
candidate.Decision == ApprovedState,
ct);
if (alreadyApproved)
{
return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true);
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail.ToLower())
.Distinct()
.CountAsync(ct);
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
approval.State = ApprovedState;
approval.CompletedAt = DateTimeOffset.UtcNow;
ApprovalRequest? nextStep = await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId &&
candidate.State == PendingState &&
candidate.Id != approval.Id)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
if (nextStep is null)
{
ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct);
if (workflowInstance is null)
{
return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true);
}
workflowInstance.State = ApprovedState;
workflowInstance.CompletedAt = DateTimeOffset.UtcNow;
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
await dbContext.SaveChangesAsync(ct);
if (nextStep is null)
{
await NotifyPublishUsersAsync(approval, contentItem, ct);
}
else
{
await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct);
}
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
public async Task<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
{
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
ct);
}
private async Task<ApprovalRequest?> GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct)
{
return await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
}
private async Task<bool> CanApproveStepAsync(
ClaimsPrincipal user,
ApprovalRequest approval,
Guid workspaceId,
CancellationToken ct)
{
Guid userId = user.GetUserId();
bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct);
string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets
.Where(user.IsInRole)
.ToArray();
return ApprovalWorkflowRules.CanApproveWorkflowStep(
user.IsInRole(KnownRoles.Administrator),
hasWorkspaceAccess,
userRoles,
userId,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue);
}
private async Task<bool> UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserClaims.AnyAsync(
claim => claim.UserId == userId &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue,
ct);
}
private async Task NotifyCurrentStepApproversAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetStepApproverRecipientsAsync(approval, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.step.current",
"ApprovalRequest",
approval.Id,
$"{approval.Stage} approval is ready for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""),
ct);
}
}
private async Task NotifyPublishUsersAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.workflow.completed",
"ApprovalWorkflowInstance",
approval.WorkflowInstanceId!.Value,
$"Final approval completed for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"status":"{{contentItem.Status}}"}"""),
ct);
}
}
private async Task<List<ApprovalNotificationRecipient>> GetStepApproverRecipientsAsync(
ApprovalRequest approval,
CancellationToken ct)
{
string? targetType = approval.WorkflowStepTargetType;
string? targetValue = approval.WorkflowStepTargetValue;
if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetType switch
{
ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct),
ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct),
ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct),
_ => [],
};
}
private async Task<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
{
IReadOnlyCollection<Guid> userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (userIds.Count == 0)
{
return [];
}
return await dbContext.Users
.Where(user => userIds.Contains(user.Id))
.Select(user => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetMembershipRecipientsAsync(
Guid workspaceId,
string targetValue,
CancellationToken ct)
{
string[] roles = targetValue switch
{
ApprovalMembershipTargets.Client => [KnownRoles.Client],
ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider],
_ => [],
};
return roles.Length == 0
? []
: await GetRoleRecipientsAsync(workspaceId, roles, ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
{
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
Guid workspaceId,
IReadOnlyCollection<string> roles,
CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserRoles
.Join(
dbContext.Roles,
userRole => userRole.RoleId,
role => role.Id,
(userRole, role) => new { userRole.UserId, RoleName = role.Name })
.Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName))
.Join(
dbContext.UserClaims.Where(claim =>
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue),
candidate => candidate.UserId,
claim => claim.UserId,
(candidate, _) => candidate.UserId)
.Distinct()
.Join(
dbContext.Users,
userId => userId,
user => user.Id,
(_, user) => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step)
{
return step.TargetType switch
{
ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}",
ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}",
ApprovalStepTargetTypes.Member => "Assigned members",
_ => step.TargetValue,
};
}
private static string CreateAccessToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
}

View File

@@ -3,8 +3,10 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
@@ -27,6 +29,7 @@ public class CreateAssetRevisionRequestValidator
public class CreateAssetRevisionHandler( public class CreateAssetRevisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto> : Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
{ {
@@ -51,7 +54,7 @@ public class CreateAssetRevisionHandler(
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct); .SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null && if (contentItem is not null &&
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) !await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -78,6 +81,25 @@ public class CreateAssetRevisionHandler(
if (contentItem is not null) if (contentItem is not null)
{ {
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.revision.created",
"AssetRevision",
revision.Id,
$"A new asset revision was added to {asset.DisplayName}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
assetId = asset.Id,
revisionNumber,
sourceReference = revision.SourceReference,
notes = revision.Notes,
})),
ct);
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
asset.WorkspaceId, asset.WorkspaceId,

View File

@@ -3,8 +3,10 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
@@ -35,6 +37,7 @@ public class CreateGoogleDriveAssetRequestValidator
public class CreateGoogleDriveAssetHandler( public class CreateGoogleDriveAssetHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto> : Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
{ {
@@ -58,7 +61,7 @@ public class CreateGoogleDriveAssetHandler(
return; return;
} }
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -93,6 +96,25 @@ public class CreateGoogleDriveAssetHandler(
dbContext.AssetRevisions.Add(revision); dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.google-drive-linked",
"Asset",
asset.Id,
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
assetType = asset.AssetType,
sourceType = asset.SourceType,
googleDriveFileId = asset.GoogleDriveFileId,
currentRevisionNumber = asset.CurrentRevisionNumber,
})),
ct);
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
asset.WorkspaceId, asset.WorkspaceId,

View File

@@ -52,7 +52,7 @@ public class GetAssetsHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -0,0 +1,18 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarCatalogEntry
{
public Guid Id { get; init; }
public required string Title { get; set; }
public required string Description { get; set; }
public string? Country { get; set; }
public string? Region { get; set; }
public required string Language { get; set; }
public required string Category { get; set; }
public string? CultureOrReligion { get; set; }
public required string ProviderName { get; set; }
public required string SourceUrl { get; set; }
public required string TrustLevel { get; set; }
public required string DefaultColor { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,53 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public static class CalendarCatalogSeed
{
public static readonly CalendarCatalogEntry[] Entries =
[
new()
{
Id = Guid.Parse("10000000-0000-0000-0000-000000000001"),
Title = "United States Public Holidays",
Description = "Federal public holiday calendar for the United States.",
Country = "US",
Region = null,
Language = "en",
Category = "public-holiday",
CultureOrReligion = null,
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US",
TrustLevel = "Verified",
DefaultColor = "#2F80ED",
},
new()
{
Id = Guid.Parse("10000000-0000-0000-0000-000000000002"),
Title = "Canada Public Holidays",
Description = "Public holiday calendar for Canada.",
Country = "CA",
Region = null,
Language = "en",
Category = "public-holiday",
CultureOrReligion = null,
ProviderName = "Nager.Date",
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA",
TrustLevel = "Verified",
DefaultColor = "#2F80ED",
},
new()
{
Id = Guid.Parse("10000000-0000-0000-0000-000000000003"),
Title = "Common Marketing Moments",
Description = "Common retail, awareness, and social planning moments.",
Country = null,
Region = null,
Language = "en",
Category = "marketing-moment",
CultureOrReligion = null,
ProviderName = "Socialize",
SourceUrl = "https://example.com/socialize/marketing-moments.ics",
TrustLevel = "Maintained",
DefaultColor = "#9B51E0",
},
];
}

View File

@@ -0,0 +1,24 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarEvent
{
public Guid Id { get; init; }
public Guid CalendarSourceId { get; set; }
public required string SourceEventUid { get; set; }
public required string Title { get; set; }
public string? Description { get; set; }
public bool IsAllDay { get; set; }
public bool IsFloatingTime { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public DateTime? StartLocalDateTime { get; set; }
public DateTime? EndLocalDateTime { get; set; }
public DateTimeOffset? StartUtc { get; set; }
public DateTimeOffset? EndUtc { get; set; }
public string? TimeZoneId { get; set; }
public string? RecurrenceId { get; set; }
public string? Location { get; set; }
public string? SourceUrl { get; set; }
public DateTimeOffset? SourceLastModifiedAt { get; set; }
public DateTimeOffset ImportedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarSource
{
public Guid Id { get; init; }
public required string Scope { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? WorkspaceId { get; set; }
public Guid? UserId { get; set; }
public string? SourceUrl { get; set; }
public string? CatalogSourceReference { get; set; }
public required string DisplayTitle { get; set; }
public required string Color { get; set; }
public required string Category { get; set; }
public bool IsEnabled { get; set; } = true;
public string? InheritanceMode { get; set; }
public DateTimeOffset? LastSuccessfulSyncAt { get; set; }
public DateTimeOffset? LastAttemptedSyncAt { get; set; }
public string? LastSyncError { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public static class CalendarSourceModelConfiguration
{
public static ModelBuilder ConfigureCalendarIntegrationsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<CalendarSource>(source =>
{
source.ToTable("CalendarSources");
source.HasKey(x => x.Id);
source.Property(x => x.Scope).HasMaxLength(32).IsRequired();
source.Property(x => x.SourceUrl).HasMaxLength(2048);
source.Property(x => x.CatalogSourceReference).HasMaxLength(256);
source.Property(x => x.DisplayTitle).HasMaxLength(256).IsRequired();
source.Property(x => x.Color).HasMaxLength(16).IsRequired();
source.Property(x => x.Category).HasMaxLength(64).IsRequired();
source.Property(x => x.InheritanceMode).HasMaxLength(32);
source.Property(x => x.LastSyncError).HasMaxLength(2048);
source.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
source.Property(x => x.UpdatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
source.HasIndex(x => x.Scope);
source.HasIndex(x => x.OrganizationId);
source.HasIndex(x => x.WorkspaceId);
source.HasIndex(x => x.UserId);
});
modelBuilder.Entity<CalendarCatalogEntry>(entry =>
{
entry.ToTable("CalendarCatalogEntries");
entry.HasKey(x => x.Id);
entry.Property(x => x.Title).HasMaxLength(256).IsRequired();
entry.Property(x => x.Description).HasMaxLength(1024).IsRequired();
entry.Property(x => x.Country).HasMaxLength(2);
entry.Property(x => x.Region).HasMaxLength(128);
entry.Property(x => x.Language).HasMaxLength(16).IsRequired();
entry.Property(x => x.Category).HasMaxLength(64).IsRequired();
entry.Property(x => x.CultureOrReligion).HasMaxLength(128);
entry.Property(x => x.ProviderName).HasMaxLength(128).IsRequired();
entry.Property(x => x.SourceUrl).HasMaxLength(2048).IsRequired();
entry.Property(x => x.TrustLevel).HasMaxLength(64).IsRequired();
entry.Property(x => x.DefaultColor).HasMaxLength(16).IsRequired();
entry.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
entry.HasIndex(x => x.Country);
entry.HasIndex(x => x.Category);
entry.HasIndex(x => x.ProviderName);
entry.HasData(CalendarCatalogSeed.Entries);
});
modelBuilder.Entity<CalendarEvent>(calendarEvent =>
{
calendarEvent.ToTable("CalendarEvents");
calendarEvent.HasKey(x => x.Id);
calendarEvent.Property(x => x.SourceEventUid).HasMaxLength(512).IsRequired();
calendarEvent.Property(x => x.Title).HasMaxLength(512).IsRequired();
calendarEvent.Property(x => x.Description).HasMaxLength(4000);
calendarEvent.Property(x => x.TimeZoneId).HasMaxLength(128);
calendarEvent.Property(x => x.RecurrenceId).HasMaxLength(512);
calendarEvent.Property(x => x.Location).HasMaxLength(512);
calendarEvent.Property(x => x.SourceUrl).HasMaxLength(2048);
calendarEvent.HasIndex(x => x.CalendarSourceId);
calendarEvent.HasIndex(x => new { x.CalendarSourceId, x.SourceEventUid, x.StartDate }).IsUnique();
calendarEvent.HasOne<CalendarSource>()
.WithMany()
.HasForeignKey(x => x.CalendarSourceId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UserCalendarExportFeed>(feed =>
{
feed.ToTable("UserCalendarExportFeeds");
feed.HasKey(x => x.Id);
feed.Property(x => x.Token).HasMaxLength(96);
feed.Property(x => x.TokenHash).HasMaxLength(64);
feed.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
feed.Property(x => x.UpdatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
feed.HasIndex(x => x.UserId).IsUnique();
feed.HasIndex(x => x.TokenHash).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class UserCalendarExportFeed
{
public Guid Id { get; init; }
public Guid UserId { get; set; }
public string? Token { get; set; }
public string? TokenHash { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Socialize.Api.Modules.CalendarIntegrations;
public static class DependencyInjection
{
public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder)
{
builder.Services.AddSingleton<Services.IcsCalendarParser>();
builder.Services.AddSingleton<Services.CalendarExportFeedBuilder>();
builder.Services.AddScoped<Services.CalendarExportFeedService>();
builder.Services.AddScoped<Services.CalendarImportSyncService>();
builder.Services.AddHostedService<Services.CalendarImportBackgroundService>();
return builder;
}
}

View File

@@ -0,0 +1,112 @@
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record CalendarSourceDto(
Guid Id,
string Scope,
Guid? OrganizationId,
Guid? WorkspaceId,
Guid? UserId,
string? SourceUrl,
string? CatalogSourceReference,
string DisplayTitle,
string Color,
string Category,
bool IsEnabled,
string? InheritanceMode,
bool IsReadOnly,
DateTimeOffset? LastSuccessfulSyncAt,
DateTimeOffset? LastAttemptedSyncAt,
string? LastSyncError,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt)
{
public static CalendarSourceDto FromSource(CalendarSource source, bool isReadOnly)
{
return new CalendarSourceDto(
source.Id,
source.Scope,
source.OrganizationId,
source.WorkspaceId,
source.UserId,
source.SourceUrl,
source.CatalogSourceReference,
source.DisplayTitle,
source.Color,
source.Category,
source.IsEnabled,
source.InheritanceMode,
isReadOnly,
source.LastSuccessfulSyncAt,
source.LastAttemptedSyncAt,
source.LastSyncError,
source.CreatedAt,
source.UpdatedAt);
}
}
public record UpsertCalendarSourceRequest(
string Scope,
Guid? OrganizationId,
Guid? WorkspaceId,
string? SourceUrl,
string? CatalogSourceReference,
string DisplayTitle,
string Color,
string Category,
bool IsEnabled,
string? InheritanceMode);
public class UpsertCalendarSourceRequestValidator
: FastEndpoints.Validator<UpsertCalendarSourceRequest>
{
public UpsertCalendarSourceRequestValidator()
{
RuleFor(x => x.Scope)
.NotEmpty()
.Must(CalendarSourceRules.IsSupportedScope)
.WithMessage("A valid calendar source scope should be specified.");
RuleFor(x => x.DisplayTitle).NotEmpty().MaximumLength(256);
RuleFor(x => x.Category).NotEmpty().MaximumLength(64);
RuleFor(x => x.Color)
.NotEmpty()
.Matches("^#[0-9A-Fa-f]{6}$")
.WithMessage("Color should be a six digit hex color, for example #2F80ED.");
RuleFor(x => x.SourceUrl)
.MaximumLength(2048)
.Must(value => string.IsNullOrWhiteSpace(value) || Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
.WithMessage("Source URL should be an absolute HTTP or HTTPS URL.");
RuleFor(x => x.CatalogSourceReference).MaximumLength(256);
RuleFor(x => x)
.Must(x => !string.IsNullOrWhiteSpace(x.SourceUrl) || !string.IsNullOrWhiteSpace(x.CatalogSourceReference))
.WithMessage("A source URL or catalog source reference should be specified.");
RuleFor(x => x)
.Must(x => x.Scope != CalendarSourceScopes.Organization || x.OrganizationId.HasValue)
.WithMessage("Organization calendar sources require an organization id.");
RuleFor(x => x)
.Must(x => x.Scope != CalendarSourceScopes.Workspace || x.WorkspaceId.HasValue)
.WithMessage("Workspace calendar sources require a workspace id.");
RuleFor(x => x)
.Must(x => x.Scope != CalendarSourceScopes.User || (!x.OrganizationId.HasValue && !x.WorkspaceId.HasValue))
.WithMessage("User calendar sources should not include organization or workspace ids.");
RuleFor(x => x)
.Must(x => x.Scope == CalendarSourceScopes.Organization ||
(!x.OrganizationId.HasValue && string.IsNullOrWhiteSpace(x.InheritanceMode)))
.WithMessage("Only organization calendar sources can set organization ids or inheritance modes.");
RuleFor(x => x.InheritanceMode)
.Must(value => string.IsNullOrWhiteSpace(value) || CalendarSourceRules.IsSupportedInheritanceMode(value))
.WithMessage("A valid inheritance mode should be specified.");
}
}

View File

@@ -0,0 +1,132 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class CreateCalendarSourceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService)
: Endpoint<UpsertCalendarSourceRequest, CalendarSourceDto>
{
public override void Configure()
{
Post("/api/calendar-integrations/sources");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
Guid currentUserId = User.GetUserId();
string scope = request.Scope.Trim();
Guid? organizationId = request.OrganizationId;
Guid? workspaceId = request.WorkspaceId;
if (!await CanCreateAsync(scope, organizationId, workspaceId, currentUserId, ct))
{
await SendForbiddenAsync(ct);
return;
}
string? sourceUrl = NormalizeOptional(request.SourceUrl);
string? catalogSourceReference = NormalizeOptional(request.CatalogSourceReference);
if (await SourceAlreadyExistsAsync(scope, organizationId, workspaceId, currentUserId, sourceUrl, catalogSourceReference, ct))
{
AddError(request => request.SourceUrl, "This calendar source has already been added.");
await SendErrorsAsync(cancellation: ct);
return;
}
CalendarSource source = new()
{
Id = Guid.NewGuid(),
Scope = scope,
OrganizationId = scope == CalendarSourceScopes.Organization ? organizationId : null,
WorkspaceId = scope == CalendarSourceScopes.Workspace ? workspaceId : null,
UserId = scope == CalendarSourceScopes.User ? currentUserId : null,
SourceUrl = sourceUrl,
CatalogSourceReference = catalogSourceReference,
DisplayTitle = request.DisplayTitle.Trim(),
Color = request.Color.Trim(),
Category = request.Category.Trim(),
IsEnabled = request.IsEnabled,
InheritanceMode = scope == CalendarSourceScopes.Organization
? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional
: null,
UpdatedAt = DateTimeOffset.UtcNow,
};
dbContext.CalendarSources.Add(source);
await dbContext.SaveChangesAsync(ct);
await SendAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), StatusCodes.Status201Created, ct);
}
private async Task<bool> CanCreateAsync(
string scope,
Guid? organizationId,
Guid? workspaceId,
Guid currentUserId,
CancellationToken ct)
{
return scope switch
{
CalendarSourceScopes.Organization when organizationId.HasValue =>
await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId.Value, ct) &&
await organizationAccessService.HasOrganizationPermissionAsync(
User,
organizationId.Value,
OrganizationPermissions.ManageConnectors,
ct),
CalendarSourceScopes.Workspace when workspaceId.HasValue =>
await dbContext.Workspaces.AnyAsync(workspace => workspace.Id == workspaceId.Value, ct) &&
await accessScopeService.CanManageWorkspaceAsync(User, workspaceId.Value, ct),
CalendarSourceScopes.User => currentUserId != Guid.Empty,
_ => false,
};
}
private Task<bool> SourceAlreadyExistsAsync(
string scope,
Guid? organizationId,
Guid? workspaceId,
Guid currentUserId,
string? sourceUrl,
string? catalogSourceReference,
CancellationToken ct)
{
IQueryable<CalendarSource> query = dbContext.CalendarSources
.Where(source => source.Scope == scope);
query = scope switch
{
CalendarSourceScopes.Organization => query.Where(source => source.OrganizationId == organizationId),
CalendarSourceScopes.Workspace => query.Where(source => source.WorkspaceId == workspaceId),
CalendarSourceScopes.User => query.Where(source => source.UserId == currentUserId),
_ => query.Where(_ => false),
};
string? normalizedUrl = sourceUrl?.Trim();
string? normalizedCatalogReference = catalogSourceReference?.Trim();
return query.AnyAsync(source =>
(!string.IsNullOrWhiteSpace(normalizedCatalogReference) &&
source.CatalogSourceReference == normalizedCatalogReference) ||
(!string.IsNullOrWhiteSpace(normalizedUrl) &&
source.SourceUrl != null &&
source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()),
ct);
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,64 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class DeleteCalendarSourceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService)
: EndpointWithoutRequest
{
public override void Configure()
{
Delete("/api/calendar-integrations/sources/{sourceId:guid}");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid sourceId = Route<Guid>("sourceId");
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
if (source is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct))
{
await SendForbiddenAsync(ct);
return;
}
dbContext.CalendarSources.Remove(source);
await dbContext.SaveChangesAsync(ct);
await SendNoContentAsync(ct);
}
private async Task<bool> CanManageExistingSourceAsync(
CalendarSource source,
Guid currentUserId,
CancellationToken ct)
{
return source.Scope switch
{
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
await organizationAccessService.HasOrganizationPermissionAsync(
User,
source.OrganizationId.Value,
OrganizationPermissions.ManageConnectors,
ct),
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
CalendarSourceScopes.User => source.UserId == currentUserId,
_ => false,
};
}
}

View File

@@ -0,0 +1,115 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public sealed class ListCalendarCatalogRequest
{
public string? Search { get; set; }
public string? Country { get; set; }
public string? Region { get; set; }
public string? Language { get; set; }
public string? Category { get; set; }
public string? CultureOrReligion { get; set; }
public string? Provider { get; set; }
}
public record CalendarCatalogEntryDto(
Guid Id,
string Title,
string Description,
string? Country,
string? Region,
string Language,
string Category,
string? CultureOrReligion,
string ProviderName,
string SourceUrl,
string TrustLevel,
string DefaultColor);
public class ListCalendarCatalogHandler(AppDbContext dbContext)
: Endpoint<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>>
{
public override void Configure()
{
Get("/api/calendar-integrations/catalog");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(ListCalendarCatalogRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
IQueryable<CalendarCatalogEntry> query = dbContext.CalendarCatalogEntries.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Search))
{
string search = request.Search.Trim().ToLowerInvariant();
query = query.Where(entry =>
entry.Title.ToLower().Contains(search) ||
entry.Description.ToLower().Contains(search) ||
entry.ProviderName.ToLower().Contains(search));
}
if (!string.IsNullOrWhiteSpace(request.Country))
{
string country = request.Country.Trim().ToUpperInvariant();
query = query.Where(entry => entry.Country == country);
}
if (!string.IsNullOrWhiteSpace(request.Region))
{
string region = request.Region.Trim();
query = query.Where(entry => entry.Region == region);
}
if (!string.IsNullOrWhiteSpace(request.Language))
{
string language = request.Language.Trim();
query = query.Where(entry => entry.Language == language);
}
if (!string.IsNullOrWhiteSpace(request.Category))
{
string category = request.Category.Trim();
query = query.Where(entry => entry.Category == category);
}
if (!string.IsNullOrWhiteSpace(request.CultureOrReligion))
{
string cultureOrReligion = request.CultureOrReligion.Trim();
query = query.Where(entry => entry.CultureOrReligion == cultureOrReligion);
}
if (!string.IsNullOrWhiteSpace(request.Provider))
{
string provider = request.Provider.Trim();
query = query.Where(entry => entry.ProviderName == provider);
}
CalendarCatalogEntryDto[] entries = await query
.OrderBy(entry => entry.Country)
.ThenBy(entry => entry.Category)
.ThenBy(entry => entry.Title)
.Take(100)
.Select(entry => new CalendarCatalogEntryDto(
entry.Id,
entry.Title,
entry.Description,
entry.Country,
entry.Region,
entry.Language,
entry.Category,
entry.CultureOrReligion,
entry.ProviderName,
entry.SourceUrl,
entry.TrustLevel,
entry.DefaultColor))
.ToArrayAsync(ct);
await SendOkAsync(entries, ct);
}
}

View File

@@ -0,0 +1,133 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public sealed class ListCalendarEventsRequest
{
public Guid? WorkspaceId { get; set; }
public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; }
}
public record CalendarEventDto(
Guid Id,
Guid CalendarSourceId,
string SourceEventUid,
string Title,
string? Description,
bool IsAllDay,
bool IsFloatingTime,
DateOnly StartDate,
DateOnly EndDate,
DateTime? StartLocalDateTime,
DateTime? EndLocalDateTime,
DateTimeOffset? StartUtc,
DateTimeOffset? EndUtc,
string? TimeZoneId,
string? RecurrenceId,
string? Location,
string? SourceUrl,
DateTimeOffset? SourceLastModifiedAt,
DateTimeOffset ImportedAt);
public class ListCalendarEventsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>>
{
public override void Configure()
{
Get("/api/calendar-integrations/events");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(ListCalendarEventsRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
Guid currentUserId = User.GetUserId();
DateOnly startDate = request.StartDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(-1));
DateOnly endDate = request.EndDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(3));
if (request.WorkspaceId.HasValue &&
!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
{
await SendForbiddenAsync(ct);
return;
}
IQueryable<CalendarSource> visibleSources = dbContext.CalendarSources
.Where(source => source.IsEnabled);
if (request.WorkspaceId.HasValue)
{
Guid? organizationId = await dbContext.Workspaces
.Where(workspace => workspace.Id == request.WorkspaceId.Value)
.Select(workspace => (Guid?)workspace.OrganizationId)
.SingleOrDefaultAsync(ct);
if (!organizationId.HasValue)
{
await SendNotFoundAsync(ct);
return;
}
visibleSources = visibleSources.Where(source =>
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == organizationId ||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == request.WorkspaceId ||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
}
else
{
IReadOnlyCollection<Guid> workspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
Guid[] organizationIds = await dbContext.Workspaces
.Where(workspace => workspaceIds.Contains(workspace.Id))
.Select(workspace => workspace.OrganizationId)
.Distinct()
.ToArrayAsync(ct);
visibleSources = visibleSources.Where(source =>
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId.HasValue && organizationIds.Contains(source.OrganizationId.Value) ||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId.HasValue && workspaceIds.Contains(source.WorkspaceId.Value) ||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
}
Guid[] sourceIds = await visibleSources
.Select(source => source.Id)
.ToArrayAsync(ct);
CalendarEventDto[] events = await dbContext.CalendarEvents
.Where(calendarEvent => sourceIds.Contains(calendarEvent.CalendarSourceId))
.Where(calendarEvent => calendarEvent.StartDate <= endDate && calendarEvent.EndDate >= startDate)
.OrderBy(calendarEvent => calendarEvent.StartDate)
.ThenBy(calendarEvent => calendarEvent.Title)
.Select(calendarEvent => new CalendarEventDto(
calendarEvent.Id,
calendarEvent.CalendarSourceId,
calendarEvent.SourceEventUid,
calendarEvent.Title,
calendarEvent.Description,
calendarEvent.IsAllDay,
calendarEvent.IsFloatingTime,
calendarEvent.StartDate,
calendarEvent.EndDate,
calendarEvent.StartLocalDateTime,
calendarEvent.EndLocalDateTime,
calendarEvent.StartUtc,
calendarEvent.EndUtc,
calendarEvent.TimeZoneId,
calendarEvent.RecurrenceId,
calendarEvent.Location,
calendarEvent.SourceUrl,
calendarEvent.SourceLastModifiedAt,
calendarEvent.ImportedAt))
.ToArrayAsync(ct);
await SendOkAsync(events, ct);
}
}

View File

@@ -0,0 +1,77 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record ListCalendarSourcesRequest(Guid? WorkspaceId);
public class ListCalendarSourcesHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>>
{
public override void Configure()
{
Get("/api/calendar-integrations/sources");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(ListCalendarSourcesRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
Guid currentUserId = User.GetUserId();
List<CalendarSource> sources;
if (request.WorkspaceId.HasValue)
{
var workspace = await dbContext.Workspaces
.Where(candidate => candidate.Id == request.WorkspaceId.Value)
.Select(candidate => new { candidate.Id, candidate.OrganizationId })
.SingleOrDefaultAsync(ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanAccessWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;
}
sources = await dbContext.CalendarSources
.Where(source =>
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == workspace.OrganizationId ||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == workspace.Id ||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
.OrderBy(source => source.Scope)
.ThenBy(source => source.DisplayTitle)
.ToListAsync(ct);
await SendOkAsync(
sources
.Select(source => CalendarSourceDto.FromSource(
source,
CalendarSourceRules.IsInheritedOrganizationSource(source, workspace.OrganizationId)))
.ToArray(),
ct);
return;
}
sources = await dbContext.CalendarSources
.Where(source => source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
.OrderBy(source => source.DisplayTitle)
.ToListAsync(ct);
await SendOkAsync(
sources.Select(source => CalendarSourceDto.FromSource(source, isReadOnly: false)).ToArray(),
ct);
}
}

View File

@@ -0,0 +1,65 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class RefreshCalendarSourceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService,
CalendarImportSyncService syncService)
: EndpointWithoutRequest<CalendarSourceDto>
{
public override void Configure()
{
Post("/api/calendar-integrations/sources/{sourceId:guid}/refresh");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid sourceId = Route<Guid>("sourceId");
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
if (source is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct))
{
await SendForbiddenAsync(ct);
return;
}
await syncService.RefreshSourceAsync(source.Id, ct);
await dbContext.Entry(source).ReloadAsync(ct);
await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct);
}
private async Task<bool> CanManageExistingSourceAsync(
CalendarSource source,
Guid currentUserId,
CancellationToken ct)
{
return source.Scope switch
{
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
await organizationAccessService.HasOrganizationPermissionAsync(
User,
source.OrganizationId.Value,
OrganizationPermissions.ManageConnectors,
ct),
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
CalendarSourceScopes.User => source.UserId == currentUserId,
_ => false,
};
}
}

View File

@@ -0,0 +1,91 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class UpdateCalendarSourceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService)
: Endpoint<UpsertCalendarSourceRequest, CalendarSourceDto>
{
public override void Configure()
{
Put("/api/calendar-integrations/sources/{sourceId:guid}");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
Guid sourceId = Route<Guid>("sourceId");
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
if (source is null)
{
await SendNotFoundAsync(ct);
return;
}
Guid currentUserId = User.GetUserId();
if (!await CanManageExistingSourceAsync(source, currentUserId, ct))
{
await SendForbiddenAsync(ct);
return;
}
if (source.Scope != request.Scope.Trim() ||
source.OrganizationId != (request.Scope == CalendarSourceScopes.Organization ? request.OrganizationId : null) ||
source.WorkspaceId != (request.Scope == CalendarSourceScopes.Workspace ? request.WorkspaceId : null))
{
AddError("Calendar source scope cannot be changed.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
source.SourceUrl = NormalizeOptional(request.SourceUrl);
source.CatalogSourceReference = NormalizeOptional(request.CatalogSourceReference);
source.DisplayTitle = request.DisplayTitle.Trim();
source.Color = request.Color.Trim();
source.Category = request.Category.Trim();
source.IsEnabled = request.IsEnabled;
source.InheritanceMode = source.Scope == CalendarSourceScopes.Organization
? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional
: null;
source.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct);
}
private async Task<bool> CanManageExistingSourceAsync(
CalendarSource source,
Guid currentUserId,
CancellationToken ct)
{
return source.Scope switch
{
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
await organizationAccessService.HasOrganizationPermissionAsync(
User,
source.OrganizationId.Value,
OrganizationPermissions.ManageConnectors,
ct),
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
CalendarSourceScopes.User => source.UserId == currentUserId,
_ => false,
};
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,224 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
using Socialize.Api.Modules.Identity.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record UserCalendarExportFeedDto(
bool IsEnabled,
string? FeedUrl,
DateTimeOffset? CreatedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? RevokedAt);
public class GetUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto>
{
public override void Configure()
{
Get("/api/calendar-integrations/export-feed");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
.SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct);
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed)), cancellation: ct);
}
}
public class EnableUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto>
{
public override void Configure()
{
Post("/api/calendar-integrations/export-feed/enable");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
string token = CalendarExportFeedTokenService.GenerateToken();
string tokenHash = CalendarExportFeedTokenService.HashToken(token);
DateTimeOffset now = DateTimeOffset.UtcNow;
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
.SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct);
if (feed is null)
{
feed = new UserCalendarExportFeed
{
Id = Guid.NewGuid(),
UserId = userId,
Token = token,
TokenHash = tokenHash,
UpdatedAt = now,
};
dbContext.UserCalendarExportFeeds.Add(feed);
}
else if (feed.TokenHash is null || feed.RevokedAt.HasValue)
{
feed.Token = token;
feed.TokenHash = tokenHash;
feed.RevokedAt = null;
feed.UpdatedAt = now;
}
else
{
token = string.Empty;
}
await dbContext.SaveChangesAsync(ct);
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct);
}
}
public class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto>
{
public override void Configure()
{
Post("/api/calendar-integrations/export-feed/regenerate");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
string token = CalendarExportFeedTokenService.GenerateToken();
DateTimeOffset now = DateTimeOffset.UtcNow;
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
.SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct);
if (feed is null)
{
feed = new UserCalendarExportFeed
{
Id = Guid.NewGuid(),
UserId = userId,
UpdatedAt = now,
};
dbContext.UserCalendarExportFeeds.Add(feed);
}
feed.TokenHash = CalendarExportFeedTokenService.HashToken(token);
feed.Token = token;
feed.RevokedAt = null;
feed.UpdatedAt = now;
await dbContext.SaveChangesAsync(ct);
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct);
}
}
public class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto>
{
public override void Configure()
{
Delete("/api/calendar-integrations/export-feed");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
.SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct);
if (feed is not null)
{
feed.TokenHash = null;
feed.Token = null;
feed.RevokedAt = DateTimeOffset.UtcNow;
feed.UpdatedAt = feed.RevokedAt.Value;
await dbContext.SaveChangesAsync(ct);
}
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, null), cancellation: ct);
}
}
public class GetUserCalendarExportFeedIcsHandler(
AppDbContext dbContext,
CalendarExportFeedService feedService)
: EndpointWithoutRequest
{
public override void Configure()
{
AllowAnonymous();
Get("/api/calendar-integrations/export-feed/{token}.ics");
Options(o => o.WithTags("Calendar Integrations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
string? token = Route<string?>("token");
if (string.IsNullOrWhiteSpace(token))
{
await SendNotFoundAsync(ct);
return;
}
string tokenHash = CalendarExportFeedTokenService.HashToken(token);
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
.SingleOrDefaultAsync(candidate =>
candidate.TokenHash == tokenHash &&
!candidate.RevokedAt.HasValue,
ct);
if (feed is null)
{
await SendNotFoundAsync(ct);
return;
}
User? user = await dbContext.Users.SingleOrDefaultAsync(candidate => candidate.Id == feed.UserId, ct);
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
string appBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
string ics = await feedService.BuildUserFeedAsync(feed.UserId, user.Email, appBaseUrl, ct);
HttpContext.Response.ContentType = "text/calendar; charset=utf-8";
await HttpContext.Response.WriteAsync(ics, ct);
}
}
file static class UserCalendarExportFeedMapper
{
public static UserCalendarExportFeedDto ToDto(UserCalendarExportFeed? feed, string? feedUrl)
{
return new UserCalendarExportFeedDto(
feed?.TokenHash is not null && !feed.RevokedAt.HasValue,
feedUrl,
feed?.CreatedAt,
feed?.UpdatedAt,
feed?.RevokedAt);
}
public static string? BuildFeedUrl(UserCalendarExportFeed? feed, string? token = null)
{
if (feed?.TokenHash is null || feed.RevokedAt.HasValue)
{
return null;
}
string effectiveToken = string.IsNullOrWhiteSpace(token) ? feed.Token ?? string.Empty : token;
return string.IsNullOrWhiteSpace(effectiveToken)
? null
: $"/api/calendar-integrations/export-feed/{effectiveToken}.ics";
}
}

View File

@@ -0,0 +1,80 @@
using System.Text;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed record CalendarExportFeedEvent(
string Uid,
string Title,
DateTimeOffset StartsAt,
DateTimeOffset EndsAt,
bool IsAllDay,
string? Description,
string? Url);
public class CalendarExportFeedBuilder
{
public string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events)
{
StringBuilder builder = new();
builder.AppendLine("BEGIN:VCALENDAR");
builder.AppendLine("VERSION:2.0");
builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN");
builder.AppendLine("CALSCALE:GREGORIAN");
builder.AppendLine("METHOD:PUBLISH");
builder.AppendLine($"X-WR-CALNAME:{EscapeText(calendarName)}");
foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt))
{
builder.AppendLine("BEGIN:VEVENT");
builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}");
builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}");
builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}");
if (feedEvent.IsAllDay)
{
builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}");
}
else
{
builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}");
}
if (!string.IsNullOrWhiteSpace(feedEvent.Description))
{
builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}");
}
if (!string.IsNullOrWhiteSpace(feedEvent.Url))
{
builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}");
}
builder.AppendLine("END:VEVENT");
}
builder.AppendLine("END:VCALENDAR");
return builder.ToString();
}
private static string FormatDate(DateTimeOffset value)
{
return value.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture);
}
private static string FormatUtc(DateTimeOffset value)
{
return value.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
private static string EscapeText(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\r\n", "\\n")
.Replace("\n", "\\n")
.Replace(";", "\\;")
.Replace(",", "\\,");
}
}

View File

@@ -0,0 +1,173 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder)
{
public async Task<string> BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct)
{
string normalizedEmail = userEmail?.Trim().ToUpperInvariant() ?? string.Empty;
Guid[] workspaceIds = await dbContext.Workspaces
.Where(workspace =>
workspace.OwnerUserId == userId ||
dbContext.OrganizationMemberships.Any(membership =>
membership.OrganizationId == workspace.OrganizationId &&
membership.UserId == userId))
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
List<CalendarExportFeedEvent> events = [];
events.AddRange(await dbContext.ContentItems
.Where(item => workspaceIds.Contains(item.WorkspaceId) && item.DueDate.HasValue)
.Join(
dbContext.Workspaces,
item => item.WorkspaceId,
workspace => workspace.Id,
(item, workspace) => new { item, workspace })
.Join(
dbContext.Clients,
itemWorkspace => itemWorkspace.item.ClientId,
client => client.Id,
(itemWorkspace, client) => new { itemWorkspace.item, itemWorkspace.workspace, client })
.Join(
dbContext.Campaigns,
itemWorkspaceClient => itemWorkspaceClient.item.CampaignId,
campaign => campaign.Id,
(itemWorkspaceClient, campaign) => new { itemWorkspaceClient.item, itemWorkspaceClient.workspace, itemWorkspaceClient.client, campaign })
.Select(candidate => ToContentFeedEvent(
candidate.item.Id,
candidate.item.Title,
candidate.item.Status,
candidate.item.DueDate!.Value,
candidate.workspace.Name,
candidate.client.Name,
candidate.campaign.Name,
appBaseUrl))
.ToListAsync(ct));
events.AddRange(await dbContext.ApprovalRequests
.Where(approval =>
approval.DueAt.HasValue &&
(approval.RequestedByUserId == userId ||
(!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail)))
.Join(
dbContext.ContentItems,
approval => approval.ContentItemId,
item => item.Id,
(approval, item) => new { approval, item })
.Where(candidate => workspaceIds.Contains(candidate.approval.WorkspaceId))
.Join(
dbContext.Workspaces,
approvalItem => approvalItem.approval.WorkspaceId,
workspace => workspace.Id,
(approvalItem, workspace) => new { approvalItem.approval, approvalItem.item, workspace })
.Select(candidate => ToApprovalFeedEvent(
candidate.approval.Id,
candidate.item.Id,
candidate.item.Title,
candidate.approval.Stage,
candidate.approval.State,
candidate.approval.DueAt!.Value,
candidate.workspace.Name,
appBaseUrl))
.ToListAsync(ct));
events.AddRange(await dbContext.Campaigns
.Where(campaign => workspaceIds.Contains(campaign.WorkspaceId))
.Join(
dbContext.Workspaces,
campaign => campaign.WorkspaceId,
workspace => workspace.Id,
(campaign, workspace) => new { campaign, workspace })
.Select(candidate => ToCampaignFeedEvent(
candidate.campaign.Id,
candidate.campaign.Name,
candidate.campaign.Status,
candidate.campaign.StartDate,
candidate.campaign.EndDate,
candidate.workspace.Name,
appBaseUrl))
.ToListAsync(ct));
return feedBuilder.Build("Socialize my work", events);
}
private static CalendarExportFeedEvent ToContentFeedEvent(
Guid contentItemId,
string title,
string status,
DateTimeOffset dueDate,
string workspaceName,
string clientName,
string campaignName,
string appBaseUrl)
{
(DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueDate);
return new CalendarExportFeedEvent(
$"content-{contentItemId}@socialize",
title,
start,
end,
isAllDay,
$"Status: {status}\nWorkspace: {workspaceName}\nClient: {clientName}\nCampaign: {campaignName}",
$"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}");
}
private static CalendarExportFeedEvent ToApprovalFeedEvent(
Guid approvalId,
Guid contentItemId,
string contentTitle,
string stage,
string state,
DateTimeOffset dueAt,
string workspaceName,
string appBaseUrl)
{
(DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueAt);
return new CalendarExportFeedEvent(
$"approval-{approvalId}@socialize",
$"Approval due: {contentTitle}",
start,
end,
isAllDay,
$"Stage: {stage}\nState: {state}\nWorkspace: {workspaceName}",
$"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}");
}
private static CalendarExportFeedEvent ToCampaignFeedEvent(
Guid campaignId,
string name,
string status,
DateTimeOffset startDate,
DateTimeOffset endDate,
string workspaceName,
string appBaseUrl)
{
DateTimeOffset start = new(startDate.Date, startDate.Offset);
DateTimeOffset end = new(endDate.Date.AddDays(1), endDate.Offset);
return new CalendarExportFeedEvent(
$"campaign-{campaignId}@socialize",
$"Campaign: {name}",
start,
end <= start ? start.AddDays(1) : end,
true,
$"Status: {status}\nWorkspace: {workspaceName}",
$"{appBaseUrl.TrimEnd('/')}/app/campaigns/{campaignId}");
}
private static (DateTimeOffset Start, DateTimeOffset End, bool IsAllDay) NormalizeEventTime(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
{
DateTimeOffset start = new(value.Date, value.Offset);
return (start, start.AddDays(1), true);
}
return (value, value.AddMinutes(30), false);
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Cryptography;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarExportFeedTokenService
{
public static string GenerateToken()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncode(bytes);
}
public static string HashToken(string token)
{
byte[] bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -0,0 +1,34 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<CalendarImportBackgroundService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromHours(6));
while (!stoppingToken.IsCancellationRequested)
{
await RefreshDueSourcesAsync(stoppingToken);
await timer.WaitForNextTickAsync(stoppingToken);
}
}
private async Task RefreshDueSourcesAsync(CancellationToken stoppingToken)
{
try
{
using IServiceScope scope = scopeFactory.CreateScope();
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
await syncService.RefreshDueSourcesAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Calendar import background sync failed.");
}
}
}

View File

@@ -0,0 +1,313 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportSyncService(
AppDbContext dbContext,
IHttpClientFactory httpClientFactory,
IcsCalendarParser parser)
{
public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct)
{
CalendarSource? source = await dbContext.CalendarSources
.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
if (source is null)
{
throw new InvalidOperationException("Calendar source was not found.");
}
source.LastAttemptedSyncAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(source.SourceUrl))
{
source.LastSyncError = "Calendar source does not have a source URL.";
await dbContext.SaveChangesAsync(ct);
return;
}
try
{
using HttpClient httpClient = httpClientFactory.CreateClient();
DateOnly rangeStart = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1));
DateOnly rangeEnd = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(2));
IReadOnlyCollection<ParsedCalendarEvent> parsedEvents = await GetParsedEventsAsync(
httpClient,
source.SourceUrl,
rangeStart,
rangeEnd,
ct);
await ReplaceEventsAsync(source.Id, parsedEvents, ct);
source.LastSuccessfulSyncAt = DateTimeOffset.UtcNow;
source.LastSyncError = null;
source.LastAttemptedSyncAt = source.LastSuccessfulSyncAt;
await dbContext.SaveChangesAsync(ct);
}
catch (HttpRequestException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
catch (FormatException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
catch (InvalidOperationException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
}
public async Task RefreshDueSourcesAsync(CancellationToken ct)
{
DateTimeOffset staleBefore = DateTimeOffset.UtcNow.AddHours(-12);
Guid[] sourceIds = await dbContext.CalendarSources
.Where(source => source.IsEnabled && source.SourceUrl != null)
.Where(source => source.LastAttemptedSyncAt == null || source.LastAttemptedSyncAt < staleBefore)
.OrderBy(source => source.LastAttemptedSyncAt)
.Select(source => source.Id)
.Take(25)
.ToArrayAsync(ct);
foreach (Guid sourceId in sourceIds)
{
await RefreshSourceAsync(sourceId, ct);
}
}
private async Task ReplaceEventsAsync(
Guid sourceId,
IReadOnlyCollection<ParsedCalendarEvent> parsedEvents,
CancellationToken ct)
{
await dbContext.CalendarEvents
.Where(calendarEvent => calendarEvent.CalendarSourceId == sourceId)
.ExecuteDeleteAsync(ct);
DateTimeOffset importedAt = DateTimeOffset.UtcNow;
foreach (ParsedCalendarEvent parsedEvent in parsedEvents)
{
dbContext.CalendarEvents.Add(new CalendarEvent
{
Id = Guid.NewGuid(),
CalendarSourceId = sourceId,
SourceEventUid = parsedEvent.SourceEventUid,
Title = parsedEvent.Title,
Description = parsedEvent.Description,
IsAllDay = parsedEvent.IsAllDay,
IsFloatingTime = parsedEvent.IsFloatingTime,
StartDate = parsedEvent.StartDate,
EndDate = parsedEvent.EndDate,
StartLocalDateTime = parsedEvent.StartLocalDateTime,
EndLocalDateTime = parsedEvent.EndLocalDateTime,
StartUtc = parsedEvent.StartUtc,
EndUtc = parsedEvent.EndUtc,
TimeZoneId = parsedEvent.TimeZoneId,
RecurrenceId = parsedEvent.RecurrenceId,
Location = parsedEvent.Location,
SourceUrl = parsedEvent.SourceUrl,
SourceLastModifiedAt = parsedEvent.SourceLastModifiedAt,
ImportedAt = importedAt,
});
}
}
private async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync(
HttpClient httpClient,
string sourceUrl,
DateOnly rangeStart,
DateOnly rangeEnd,
CancellationToken ct)
{
if (TryGetNagerCountryCode(sourceUrl, out string? countryCode))
{
return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct);
}
string content = await httpClient.GetStringAsync(sourceUrl, ct);
return parser.Parse(content, rangeStart, rangeEnd);
}
private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync(
HttpClient httpClient,
string sourceUrl,
string countryCode,
DateOnly rangeStart,
DateOnly rangeEnd,
CancellationToken ct)
{
List<ParsedCalendarEvent> events = [];
for (int year = rangeStart.Year; year <= rangeEnd.Year; year++)
{
string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year);
string json = await httpClient.GetStringAsync(yearUrl, ct);
NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>(
json,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
foreach (NagerHoliday holiday in holidays)
{
if (!DateOnly.TryParse(holiday.Date, out DateOnly date) ||
date < rangeStart ||
date > rangeEnd)
{
continue;
}
events.Add(ToParsedEvent(
$"nager-{countryCode}-{date:yyyyMMdd}-{NormalizeUidPart(holiday.Name)}",
string.IsNullOrWhiteSpace(holiday.Name) ? holiday.LocalName : holiday.Name,
holiday.LocalName,
date,
string.Join(", ", holiday.Types ?? []),
yearUrl));
}
events.AddRange(GetSupplementalCountryEvents(countryCode, year, rangeStart, rangeEnd));
}
return events
.GroupBy(calendarEvent => calendarEvent.SourceEventUid)
.Select(group => group.First())
.OrderBy(calendarEvent => calendarEvent.StartDate)
.ThenBy(calendarEvent => calendarEvent.Title)
.ToArray();
}
private static ParsedCalendarEvent ToParsedEvent(
string uid,
string? title,
string? localName,
DateOnly date,
string? types,
string sourceUrl)
{
string? description = string.IsNullOrWhiteSpace(types)
? localName
: $"{localName}\nTypes: {types}";
return new ParsedCalendarEvent(
uid,
string.IsNullOrWhiteSpace(title) ? "Untitled event" : title,
description,
IsAllDay: true,
IsFloatingTime: false,
date,
date.AddDays(1),
StartLocalDateTime: null,
EndLocalDateTime: null,
StartUtc: null,
EndUtc: null,
TimeZoneId: null,
RecurrenceId: null,
Location: null,
sourceUrl,
SourceLastModifiedAt: null);
}
private static IReadOnlyCollection<ParsedCalendarEvent> GetSupplementalCountryEvents(
string countryCode,
int year,
DateOnly rangeStart,
DateOnly rangeEnd)
{
if (!countryCode.Equals("CA", StringComparison.OrdinalIgnoreCase))
{
return [];
}
DateOnly mothersDay = NthWeekdayOfMonth(year, month: 5, DayOfWeek.Sunday, occurrence: 2);
if (mothersDay < rangeStart || mothersDay > rangeEnd)
{
return [];
}
return
[
ToParsedEvent(
$"socialize-ca-mothers-day-{year}",
"Mother's Day",
"Mother's Day",
mothersDay,
"Observance",
"socialize://calendar-observances/CA"),
];
}
private static DateOnly NthWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek, int occurrence)
{
DateOnly date = new(year, month, 1);
while (date.DayOfWeek != dayOfWeek)
{
date = date.AddDays(1);
}
return date.AddDays((occurrence - 1) * 7);
}
private static bool TryGetNagerCountryCode(string sourceUrl, out string? countryCode)
{
countryCode = null;
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri) ||
!uri.Host.Contains("date.nager.at", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string[] segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string? candidate = segments.LastOrDefault(segment => segment.Length == 2);
if (candidate is null)
{
return false;
}
countryCode = candidate.ToUpperInvariant();
return true;
}
private static string BuildNagerYearUrl(string sourceUrl, string countryCode, int year)
{
if (Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri))
{
return $"{uri.Scheme}://{uri.Host}/api/v3/PublicHolidays/{year}/{countryCode}";
}
return $"https://date.nager.at/api/v3/PublicHolidays/{year}/{countryCode}";
}
private static string NormalizeUidPart(string? value)
{
return new string((value ?? "holiday")
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray())
.Trim('-');
}
private async Task RecordSyncFailureAsync(
CalendarSource source,
string message,
CancellationToken ct)
{
source.LastSyncError = NormalizeSyncError(message);
await dbContext.SaveChangesAsync(ct);
}
public static string NormalizeSyncError(string message)
{
ArgumentNullException.ThrowIfNull(message);
return message.Length > 2048 ? message[..2048] : message;
}
private sealed record NagerHoliday(
string Date,
string LocalName,
string Name,
string[]? Types);
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarSourceScopes
{
public const string Organization = "Organization";
public const string Workspace = "Workspace";
public const string User = "User";
}
public static class CalendarSourceInheritanceModes
{
public const string Required = "Required";
public const string Optional = "Optional";
}

View File

@@ -0,0 +1,51 @@
using Socialize.Api.Modules.CalendarIntegrations.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarSourceRules
{
public static readonly string[] SupportedScopes =
[
CalendarSourceScopes.Organization,
CalendarSourceScopes.Workspace,
CalendarSourceScopes.User,
];
public static readonly string[] SupportedInheritanceModes =
[
CalendarSourceInheritanceModes.Required,
CalendarSourceInheritanceModes.Optional,
];
public static bool IsSupportedScope(string? scope)
{
return SupportedScopes.Contains(scope?.Trim(), StringComparer.Ordinal);
}
public static bool IsSupportedInheritanceMode(string? inheritanceMode)
{
return SupportedInheritanceModes.Contains(inheritanceMode?.Trim(), StringComparer.Ordinal);
}
public static bool IsInheritedOrganizationSource(CalendarSource source, Guid workspaceOrganizationId)
{
return source.Scope == CalendarSourceScopes.Organization &&
source.OrganizationId == workspaceOrganizationId;
}
public static bool CanManageScope(
string scope,
bool canManageOrganizationCalendars,
bool canManageWorkspaceCalendars,
Guid currentUserId,
Guid? sourceUserId)
{
return scope switch
{
CalendarSourceScopes.Organization => canManageOrganizationCalendars,
CalendarSourceScopes.Workspace => canManageWorkspaceCalendars,
CalendarSourceScopes.User => sourceUserId == currentUserId,
_ => false,
};
}
}

View File

@@ -0,0 +1,414 @@
using System.Globalization;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public record ParsedCalendarEvent(
string SourceEventUid,
string Title,
string? Description,
bool IsAllDay,
bool IsFloatingTime,
DateOnly StartDate,
DateOnly EndDate,
DateTime? StartLocalDateTime,
DateTime? EndLocalDateTime,
DateTimeOffset? StartUtc,
DateTimeOffset? EndUtc,
string? TimeZoneId,
string? RecurrenceId,
string? Location,
string? SourceUrl,
DateTimeOffset? SourceLastModifiedAt);
internal record IcsDateTimeValue(
bool IsAllDay,
bool IsFloatingTime,
DateOnly Date,
DateTime? LocalDateTime,
DateTimeOffset? UtcDateTime,
string? TimeZoneId);
internal sealed record IcsRawEvent(
string Uid,
string Title,
string? Description,
IcsDateTimeValue Start,
IcsDateTimeValue? End,
string? RRule,
string? Location,
string? SourceUrl,
DateTimeOffset? LastModifiedAt);
public sealed class IcsCalendarParser
{
public IReadOnlyCollection<ParsedCalendarEvent> Parse(
string content,
DateOnly rangeStart,
DateOnly rangeEnd)
{
ArgumentNullException.ThrowIfNull(content);
List<ParsedCalendarEvent> events = [];
foreach (IcsRawEvent rawEvent in ReadRawEvents(content))
{
events.AddRange(Expand(rawEvent, rangeStart, rangeEnd));
}
return events
.OrderBy(calendarEvent => calendarEvent.StartDate)
.ThenBy(calendarEvent => calendarEvent.Title)
.ToArray();
}
private static IEnumerable<IcsRawEvent> ReadRawEvents(string content)
{
List<string> lines = UnfoldLines(content).ToList();
for (int index = 0; index < lines.Count; index++)
{
if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
continue;
}
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties =
new(StringComparer.OrdinalIgnoreCase);
index++;
for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++)
{
ParseProperty(lines[index], properties);
}
if (!TryGetFirst(properties, "DTSTART", out var startProperty))
{
continue;
}
IcsDateTimeValue start = ParseDateTimeValue(startProperty.Value, startProperty.Parameters);
IcsDateTimeValue? end = TryGetFirst(properties, "DTEND", out var endProperty)
? ParseDateTimeValue(endProperty.Value, endProperty.Parameters)
: null;
string uid = TryGetFirst(properties, "UID", out var uidProperty)
? uidProperty.Value
: $"{start.Date:yyyyMMdd}:{GetText(properties, "SUMMARY") ?? "calendar-event"}";
yield return new IcsRawEvent(
uid,
GetText(properties, "SUMMARY") ?? "Untitled event",
GetText(properties, "DESCRIPTION"),
start,
end,
GetText(properties, "RRULE"),
GetText(properties, "LOCATION"),
GetText(properties, "URL"),
TryGetFirst(properties, "LAST-MODIFIED", out var lastModified)
? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime
: null);
}
}
private static IEnumerable<string> UnfoldLines(string content)
{
string? current = null;
using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'));
while (reader.ReadLine() is { } line)
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null)
{
current += line[1..];
continue;
}
if (current is not null)
{
yield return current;
}
current = line;
}
if (current is not null)
{
yield return current;
}
}
private static void ParseProperty(
string line,
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties)
{
int separatorIndex = line.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex < 0)
{
return;
}
string nameAndParameters = line[..separatorIndex];
string value = UnescapeText(line[(separatorIndex + 1)..]);
string[] nameParts = nameAndParameters.Split(';');
string name = nameParts[0];
Dictionary<string, string> parameters = new(StringComparer.OrdinalIgnoreCase);
foreach (string parameterPart in nameParts.Skip(1))
{
int equalsIndex = parameterPart.IndexOf('=', StringComparison.Ordinal);
if (equalsIndex > 0)
{
parameters[parameterPart[..equalsIndex]] = parameterPart[(equalsIndex + 1)..].Trim('"');
}
}
if (!properties.TryGetValue(name, out var values))
{
values = [];
properties[name] = values;
}
values.Add((parameters, value));
}
private static string UnescapeText(string value)
{
return value
.Replace("\\n", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("\\,", ",", StringComparison.Ordinal)
.Replace("\\;", ";", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal);
}
private static bool TryGetFirst(
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key,
out (Dictionary<string, string> Parameters, string Value) value)
{
if (properties.TryGetValue(key, out var values) && values.Count > 0)
{
value = values[0];
return true;
}
value = default;
return false;
}
private static string? GetText(
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key)
{
return TryGetFirst(properties, key, out var value) ? value.Value : null;
}
private static IcsDateTimeValue ParseDateTimeValue(
string value,
Dictionary<string, string> parameters)
{
bool isAllDay = parameters.TryGetValue("VALUE", out string? valueType) &&
valueType.Equals("DATE", StringComparison.OrdinalIgnoreCase);
if (isAllDay || value.Length == 8)
{
DateOnly date = DateOnly.ParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture);
return new IcsDateTimeValue(true, false, date, null, null, null);
}
bool utc = value.EndsWith('Z');
string parseValue = utc ? value[..^1] : value;
DateTime local = DateTime.ParseExact(parseValue, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
if (utc)
{
DateTimeOffset utcValue = new(DateTime.SpecifyKind(local, DateTimeKind.Utc));
return new IcsDateTimeValue(false, false, DateOnly.FromDateTime(local), local, utcValue, "UTC");
}
string? timeZoneId = parameters.GetValueOrDefault("TZID");
bool floating = string.IsNullOrWhiteSpace(timeZoneId);
return new IcsDateTimeValue(
false,
floating,
DateOnly.FromDateTime(local),
local,
TryConvertToUtc(local, timeZoneId),
timeZoneId);
}
private static DateTimeOffset? TryConvertToUtc(DateTime localDateTime, string? timeZoneId)
{
if (string.IsNullOrWhiteSpace(timeZoneId))
{
return null;
}
try
{
TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified);
return TimeZoneInfo.ConvertTimeToUtc(unspecified, timeZone);
}
catch (TimeZoneNotFoundException)
{
return null;
}
catch (InvalidTimeZoneException)
{
return null;
}
}
private static IEnumerable<ParsedCalendarEvent> Expand(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End);
IReadOnlyCollection<DateOnly> starts = ExpandStartDates(rawEvent, rangeStart, rangeEnd);
foreach (DateOnly startDate in starts)
{
int dayOffset = startDate.DayNumber - rawEvent.Start.Date.DayNumber;
IcsDateTimeValue occurrenceStart = Shift(rawEvent.Start, dayOffset);
IcsDateTimeValue occurrenceEnd = rawEvent.End is null
? ShiftByDuration(occurrenceStart, duration)
: Shift(rawEvent.End, dayOffset);
yield return new ParsedCalendarEvent(
rawEvent.Uid,
rawEvent.Title,
rawEvent.Description,
occurrenceStart.IsAllDay,
occurrenceStart.IsFloatingTime,
occurrenceStart.Date,
occurrenceEnd.Date,
occurrenceStart.LocalDateTime,
occurrenceEnd.LocalDateTime,
occurrenceStart.UtcDateTime,
occurrenceEnd.UtcDateTime,
occurrenceStart.TimeZoneId,
rawEvent.RRule is null ? null : rawEvent.Uid,
rawEvent.Location,
rawEvent.SourceUrl,
rawEvent.LastModifiedAt);
}
}
private static TimeSpan GetDuration(IcsDateTimeValue start, IcsDateTimeValue? end)
{
if (end is null)
{
return start.IsAllDay ? TimeSpan.FromDays(1) : TimeSpan.Zero;
}
if (start.IsAllDay)
{
return TimeSpan.FromDays(Math.Max(1, end.Date.DayNumber - start.Date.DayNumber));
}
if (start.LocalDateTime.HasValue && end.LocalDateTime.HasValue)
{
return end.LocalDateTime.Value - start.LocalDateTime.Value;
}
return TimeSpan.Zero;
}
private static IReadOnlyCollection<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
if (string.IsNullOrWhiteSpace(rawEvent.RRule))
{
return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : [];
}
Dictionary<string, string> rule = rawEvent.RRule
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2))
.Where(parts => parts.Length == 2)
.ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase);
string frequency = rule.GetValueOrDefault("FREQ", "DAILY");
int interval = int.TryParse(rule.GetValueOrDefault("INTERVAL"), out int parsedInterval)
? Math.Max(1, parsedInterval)
: 1;
int? count = int.TryParse(rule.GetValueOrDefault("COUNT"), out int parsedCount) ? parsedCount : null;
DateOnly? until = TryParseUntil(rule.GetValueOrDefault("UNTIL"));
List<DateOnly> dates = [];
DateOnly current = rawEvent.Start.Date;
for (int occurrence = 1; occurrence <= (count ?? 500); occurrence++)
{
if (until.HasValue && current > until.Value)
{
break;
}
if (current > rangeEnd)
{
break;
}
if (IsInRange(current, rangeStart, rangeEnd))
{
dates.Add(current);
}
current = frequency.ToUpperInvariant() switch
{
"YEARLY" => current.AddYears(interval),
"MONTHLY" => current.AddMonths(interval),
"WEEKLY" => current.AddDays(7 * interval),
_ => current.AddDays(interval),
};
}
return dates;
}
private static DateOnly? TryParseUntil(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string dateValue = value.EndsWith('Z') ? value[..^1] : value;
if (dateValue.Length >= 8 &&
DateOnly.TryParseExact(dateValue[..8], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date))
{
return date;
}
return null;
}
private static bool IsInRange(DateOnly value, DateOnly rangeStart, DateOnly rangeEnd)
{
return value >= rangeStart && value <= rangeEnd;
}
private static IcsDateTimeValue Shift(IcsDateTimeValue value, int dayOffset)
{
return value with
{
Date = value.Date.AddDays(dayOffset),
LocalDateTime = value.LocalDateTime?.AddDays(dayOffset),
UtcDateTime = value.UtcDateTime?.AddDays(dayOffset),
};
}
private static IcsDateTimeValue ShiftByDuration(IcsDateTimeValue value, TimeSpan duration)
{
if (value.IsAllDay)
{
return value with { Date = value.Date.AddDays(Math.Max(1, (int)duration.TotalDays)) };
}
return value with
{
Date = value.LocalDateTime.HasValue
? DateOnly.FromDateTime(value.LocalDateTime.Value.Add(duration))
: value.Date,
LocalDateTime = value.LocalDateTime?.Add(duration),
UtcDateTime = value.UtcDateTime?.Add(duration),
};
}
}

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Projects.Data; namespace Socialize.Api.Modules.Campaigns.Data;
public class Project public class Campaign
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Campaigns.Data;
public static class CampaignModelConfiguration
{
public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Campaign>(campaign =>
{
campaign.ToTable("Campaigns");
campaign.HasKey(x => x.Id);
campaign.Property(x => x.Name).HasMaxLength(256).IsRequired();
campaign.Property(x => x.Description).HasMaxLength(4000);
campaign.Property(x => x.Notes).HasMaxLength(4000);
campaign.Property(x => x.Status).HasMaxLength(64).IsRequired();
campaign.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
campaign.HasIndex(x => x.WorkspaceId);
campaign.HasIndex(x => x.ClientId);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,12 @@
using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns;
public static class DependencyInjection
{
public static WebApplicationBuilder AddCampaignsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -2,11 +2,11 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Projects.Data; using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Projects.Handlers; namespace Socialize.Api.Modules.Campaigns.Handlers;
public record CreateProjectRequest( public record CreateCampaignRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
string Name, string Name,
@@ -15,10 +15,10 @@ public record CreateProjectRequest(
string? Description, string? Description,
string? Notes); string? Notes);
public class CreateProjectRequestValidator public class CreateCampaignRequestValidator
: Validator<CreateProjectRequest> : Validator<CreateCampaignRequest>
{ {
public CreateProjectRequestValidator() public CreateCampaignRequestValidator()
{ {
RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty(); RuleFor(x => x.ClientId).NotEmpty();
@@ -32,20 +32,20 @@ public class CreateProjectRequestValidator
} }
} }
public class CreateProjectHandler( public class CreateCampaignHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<CreateProjectRequest, ProjectDto> : Endpoint<CreateCampaignRequest, CampaignDto>
{ {
public override void Configure() public override void Configure()
{ {
Post("/api/projects"); Post("/api/campaigns");
Options(o => o.WithTags("Projects")); Options(o => o.WithTags("Campaigns"));
} }
public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct) public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
{ {
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -75,19 +75,19 @@ public class CreateProjectHandler(
string normalizedName = request.Name.Trim(); string normalizedName = request.Name.Trim();
bool duplicateProject = await dbContext.Projects bool duplicateCampaign = await dbContext.Campaigns
.AnyAsync( .AnyAsync(
project => project.ClientId == request.ClientId && project.Name == normalizedName, campaign => campaign.ClientId == request.ClientId && campaign.Name == normalizedName,
ct); ct);
if (duplicateProject) if (duplicateCampaign)
{ {
AddError(request => request.Name, "A project with this name already exists for the selected client."); AddError(request => request.Name, "A campaign with this name already exists for the selected client.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct); await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return; return;
} }
Project project = new() Campaign campaign = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId, WorkspaceId = request.WorkspaceId,
@@ -101,19 +101,19 @@ public class CreateProjectHandler(
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
dbContext.Projects.Add(project); dbContext.Campaigns.Add(campaign);
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
ProjectDto dto = new( CampaignDto dto = new(
project.Id, campaign.Id,
project.WorkspaceId, campaign.WorkspaceId,
project.ClientId, campaign.ClientId,
project.Name, campaign.Name,
project.Description, campaign.Description,
project.Notes, campaign.Notes,
project.Status, campaign.Status,
project.StartDate, campaign.StartDate,
project.EndDate); campaign.EndDate);
await SendAsync(dto, StatusCodes.Status201Created, ct); await SendAsync(dto, StatusCodes.Status201Created, ct);
} }

View File

@@ -0,0 +1,82 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns.Handlers;
public record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId);
public record CampaignDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
string Name,
string? Description,
string? Notes,
string Status,
DateTimeOffset StartDate,
DateTimeOffset EndDate);
public class GetCampaignsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetCampaignsRequest, IReadOnlyCollection<CampaignDto>>
{
public override void Configure()
{
Get("/api/campaigns");
Options(o => o.WithTags("Campaigns"));
}
public override async Task HandleAsync(GetCampaignsRequest request, CancellationToken ct)
{
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
}
if (campaignScopeIds.Count > 0)
{
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
}
}
if (request.ClientId.HasValue)
{
query = query.Where(campaign => campaign.ClientId == request.ClientId.Value);
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
}
List<CampaignDto> campaigns = await query
.OrderBy(campaign => campaign.Name)
.Select(campaign => new CampaignDto(
campaign.Id,
campaign.WorkspaceId,
campaign.ClientId,
campaign.Name,
campaign.Description,
campaign.Notes,
campaign.Status,
campaign.StartDate,
campaign.EndDate))
.ToListAsync(ct);
await SendOkAsync(campaigns, ct);
}
}

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Channels.Data;
public class Channel
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string Network { get; set; }
public string? Handle { get; set; }
public string? ExternalUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Channels.Data;
public static class ChannelModelConfiguration
{
public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Channel>(channel =>
{
channel.ToTable("Channels");
channel.HasKey(x => x.Id);
channel.Property(x => x.Name).HasMaxLength(256).IsRequired();
channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
channel.Property(x => x.Handle).HasMaxLength(256);
channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
channel.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
channel.HasIndex(x => x.WorkspaceId);
channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels;
public static class DependencyInjection
{
public static WebApplicationBuilder AddChannelsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels.Handlers;
public record ChannelDto(
Guid Id,
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,115 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record CreateChannelRequest(
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl);
public class CreateChannelRequestValidator
: Validator<CreateChannelRequest>
{
private static readonly string[] AllowedNetworks =
[
"Instagram",
"TikTok",
"Facebook",
"LinkedIn",
"YouTube",
"X",
"Reddit",
"Website",
];
public CreateChannelRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Network).NotEmpty().Must(network => AllowedNetworks.Contains(network))
.WithMessage("Selected network is invalid.");
RuleFor(x => x.Handle).MaximumLength(256);
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
}
}
public class CreateChannelHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateChannelRequest, ChannelDto>
{
public override void Configure()
{
Post("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(CreateChannelRequest request, CancellationToken ct)
{
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedNetwork = request.Network.Trim();
string? normalizedHandle = request.Handle?.Trim();
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
bool duplicateChannel = await dbContext.Channels
.AnyAsync(
channel => channel.WorkspaceId == request.WorkspaceId
&& channel.Network == normalizedNetwork
&& channel.Name == normalizedName,
ct);
if (duplicateChannel)
{
AddError(request => request.Name, "A channel with this name already exists for the selected network.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Channel channel = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
Name = normalizedName,
Network = normalizedNetwork,
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
await dbContext.SaveChangesAsync(ct);
ChannelDto dto = new(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,52 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record GetChannelsRequest(Guid? WorkspaceId);
public class GetChannelsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetChannelsRequest, IReadOnlyCollection<ChannelDto>>
{
public override void Configure()
{
Get("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(GetChannelsRequest request, CancellationToken ct)
{
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(channel => channel.WorkspaceId == request.WorkspaceId.Value);
}
List<ChannelDto> channels = await query
.OrderBy(channel => channel.Network)
.ThenBy(channel => channel.Name)
.Select(channel => new ChannelDto(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(channels, ct);
}
}

View File

@@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler(
return; return;
} }
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -41,7 +41,7 @@ public class CreateClientHandler(
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct) public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{ {
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -33,16 +33,9 @@ public class GetClientsHandler(
{ {
IQueryable<Client> query = dbContext.Clients.AsQueryable(); IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User)) if (!accessScopeService.IsManager(User))
{ {
if (request.WorkspaceId.HasValue) IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId)); query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
@@ -52,10 +45,11 @@ public class GetClientsHandler(
query = query.Where(client => clientScopeIds.Contains(client.Id)); query = query.Where(client => clientScopeIds.Contains(client.Id));
} }
if (request.WorkspaceId.HasValue) }
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); if (request.WorkspaceId.HasValue)
} {
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
} }
List<ClientDto> clients = await query List<ClientDto> clients = await query

View File

@@ -50,7 +50,7 @@ public class UpdateClientHandler(
return; return;
} }
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -10,7 +10,11 @@ public class Comment
public required string AuthorDisplayName { get; set; } public required string AuthorDisplayName { get; set; }
public required string AuthorEmail { get; set; } public required string AuthorEmail { get; set; }
public required string Body { get; set; } public required string Body { get; set; }
public bool IsResolved { get; set; } public string? AttachmentFileName { get; set; }
public string? AttachmentContentType { get; set; }
public long? AttachmentSizeBytes { get; set; }
public string? AttachmentBlobContainerName { get; set; }
public string? AttachmentBlobName { get; set; }
public string? AttachmentBlobUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; set; }
} }

View File

@@ -13,6 +13,11 @@ public static class CommentModelConfiguration
comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired(); comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired();
comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired(); comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired();
comment.Property(x => x.Body).HasMaxLength(4000).IsRequired(); comment.Property(x => x.Body).HasMaxLength(4000).IsRequired();
comment.Property(x => x.AttachmentFileName).HasMaxLength(256);
comment.Property(x => x.AttachmentContentType).HasMaxLength(128);
comment.Property(x => x.AttachmentBlobContainerName).HasMaxLength(128);
comment.Property(x => x.AttachmentBlobName).HasMaxLength(512);
comment.Property(x => x.AttachmentBlobUrl).HasMaxLength(1024);
comment.Property(x => x.CreatedAt) comment.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");

View File

@@ -1,10 +1,13 @@
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Comments.Handlers; namespace Socialize.Api.Modules.Comments.Handlers;
@@ -12,7 +15,8 @@ public record CreateCommentRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
Guid? ParentCommentId, Guid? ParentCommentId,
string Body); string Body,
IFormFile? Attachment);
public class CreateCommentRequestValidator public class CreateCommentRequestValidator
: Validator<CreateCommentRequest> : Validator<CreateCommentRequest>
@@ -21,13 +25,15 @@ public class CreateCommentRequestValidator
{ {
RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty(); RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000); RuleFor(x => x.Body).MaximumLength(4000);
} }
} }
public class CreateCommentHandler( public class CreateCommentHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IBlobStorage blobStorage,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto> : Endpoint<CreateCommentRequest, CommentDto>
{ {
@@ -35,10 +41,19 @@ public class CreateCommentHandler(
{ {
Post("/api/comments"); Post("/api/comments");
Options(o => o.WithTags("Comments")); Options(o => o.WithTags("Comments"));
AllowFileUploads();
} }
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct) public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
{ {
string body = request.Body?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(body) && request.Attachment is null)
{
AddError(request => request.Body, "A comment body or attachment is required.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
@@ -51,7 +66,7 @@ public class CreateCommentHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -72,16 +87,70 @@ public class CreateCommentHandler(
} }
} }
Guid commentId = Guid.NewGuid();
string? attachmentFileName = null;
string? attachmentContentType = null;
long? attachmentSizeBytes = null;
string? attachmentBlobName = null;
string? attachmentBlobUrl = null;
if (request.Attachment is not null)
{
string normalizedContentType = request.Attachment.ContentType.Trim().ToLowerInvariant();
if (request.Attachment.Length <= 0)
{
AddError(request => request.Attachment, "The attachment must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!IsInlineAttachmentContentType(normalizedContentType))
{
AddError(request => request.Attachment, "The attachment must be a PNG or JPEG image.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
attachmentFileName = NormalizeFileName(request.Attachment.FileName, normalizedContentType);
attachmentContentType = normalizedContentType;
attachmentSizeBytes = request.Attachment.Length;
attachmentBlobName =
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/comments/{commentId}/{attachmentFileName}";
try
{
attachmentBlobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
attachmentBlobName,
request.Attachment.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.Attachment, "The attachment file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
Comment comment = new() Comment comment = new()
{ {
Id = Guid.NewGuid(), Id = commentId,
WorkspaceId = request.WorkspaceId, WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId, ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId, ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(), AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(), AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(), AuthorEmail = User.GetEmail(),
Body = request.Body.Trim(), Body = body,
AttachmentFileName = attachmentFileName,
AttachmentContentType = attachmentContentType,
AttachmentSizeBytes = attachmentSizeBytes,
AttachmentBlobContainerName = attachmentBlobName is not null ? ContainerNames.Workspaces : null,
AttachmentBlobName = attachmentBlobName,
AttachmentBlobUrl = attachmentBlobUrl,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -93,6 +162,23 @@ public class CreateCommentHandler(
.Select(candidate => candidate.PortraitUrl) .Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.created",
"Comment",
comment.Id,
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
comment.AuthorUserId,
comment.AuthorEmail,
JsonSerializer.Serialize(new
{
parentCommentId = comment.ParentCommentId,
attachmentFileName = comment.AttachmentFileName,
})),
ct);
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
comment.WorkspaceId, comment.WorkspaceId,
@@ -116,10 +202,34 @@ public class CreateCommentHandler(
comment.AuthorEmail, comment.AuthorEmail,
authorPortraitUrl, authorPortraitUrl,
comment.Body, comment.Body,
comment.IsResolved, comment.AttachmentFileName,
comment.CreatedAt, comment.AttachmentContentType,
comment.ResolvedAt); comment.AttachmentSizeBytes,
comment.AttachmentBlobUrl,
comment.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct); await SendAsync(dto, StatusCodes.Status201Created, ct);
} }
private static bool IsInlineAttachmentContentType(string contentType)
{
return contentType.Trim().ToLowerInvariant() is "image/png" or "image/jpeg" or "image/jpg";
}
private static string NormalizeFileName(string? fileName, string contentType)
{
string extension = contentType.Trim().ToLowerInvariant() switch
{
"image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg",
_ => string.Empty,
};
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return $"comment-attachment{extension}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
} }

View File

@@ -19,9 +19,11 @@ public record CommentDto(
string AuthorEmail, string AuthorEmail,
string? AuthorPortraitUrl, string? AuthorPortraitUrl,
string Body, string Body,
bool IsResolved, string? AttachmentFileName,
DateTimeOffset CreatedAt, string? AttachmentContentType,
DateTimeOffset? ResolvedAt); long? AttachmentSizeBytes,
string? AttachmentBlobUrl,
DateTimeOffset CreatedAt);
public class GetCommentsHandler( public class GetCommentsHandler(
AppDbContext dbContext, AppDbContext dbContext,
@@ -44,7 +46,7 @@ public class GetCommentsHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -75,9 +77,11 @@ public class GetCommentsHandler(
comment.AuthorEmail, comment.AuthorEmail,
authorPortraits.GetValueOrDefault(comment.AuthorUserId), authorPortraits.GetValueOrDefault(comment.AuthorUserId),
comment.Body, comment.Body,
comment.IsResolved, comment.AttachmentFileName,
comment.CreatedAt, comment.AttachmentContentType,
comment.ResolvedAt)) comment.AttachmentSizeBytes,
comment.AttachmentBlobUrl,
comment.CreatedAt))
.ToList(); .ToList();
await SendOkAsync(dtos, ct); await SendOkAsync(dtos, ct);

View File

@@ -1,89 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Api.Modules.Comments.Handlers;
public class ResolveCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: EndpointWithoutRequest<CommentDto>
{
public override void Configure()
{
Post("/api/comments/{id}/resolve");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (comment is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
if (!canResolve)
{
await SendForbiddenAsync(ct);
return;
}
comment.IsResolved = true;
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.resolved",
"Comment",
comment.Id,
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
null,
null,
null),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Api.Modules.ContentItems.Contracts;
public record ContentItemActivityWriteModel(
Guid WorkspaceId,
Guid ContentItemId,
string EventType,
string EntityType,
Guid EntityId,
string Summary,
Guid? ActorUserId,
string? ActorEmail,
string? MetadataJson);
public interface IContentItemActivityWriter
{
Task WriteAsync(ContentItemActivityWriteModel model, CancellationToken cancellationToken = default);
}

View File

@@ -5,7 +5,7 @@ public class ContentItem
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; } public Guid ClientId { get; set; }
public Guid ProjectId { get; set; } public Guid CampaignId { get; set; }
public required string Title { get; set; } public required string Title { get; set; }
public required string PublicationMessage { get; set; } public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; } public required string PublicationTargets { get; set; }

View File

@@ -0,0 +1,16 @@
namespace Socialize.Api.Modules.ContentItems.Data;
public class ContentItemActivityEntry
{
public Guid Id { get; set; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string EventType { get; set; }
public required string EntityType { get; set; }
public Guid EntityId { get; set; }
public required string Summary { get; set; }
public Guid? ActorUserId { get; set; }
public string? ActorEmail { get; set; }
public string? MetadataJson { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -21,7 +21,7 @@ public static class ContentItemModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
contentItem.HasIndex(x => x.WorkspaceId); contentItem.HasIndex(x => x.WorkspaceId);
contentItem.HasIndex(x => x.ClientId); contentItem.HasIndex(x => x.ClientId);
contentItem.HasIndex(x => x.ProjectId); contentItem.HasIndex(x => x.CampaignId);
}); });
modelBuilder.Entity<ContentItemRevision>(revision => modelBuilder.Entity<ContentItemRevision>(revision =>
@@ -41,6 +41,23 @@ public static class ContentItemModelConfiguration
revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique(); revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique();
}); });
modelBuilder.Entity<ContentItemActivityEntry>(entry =>
{
entry.ToTable("ContentItemActivityEntries");
entry.HasKey(x => x.Id);
entry.Property(x => x.EventType).HasMaxLength(128).IsRequired();
entry.Property(x => x.EntityType).HasMaxLength(128).IsRequired();
entry.Property(x => x.Summary).HasMaxLength(1024).IsRequired();
entry.Property(x => x.ActorEmail).HasMaxLength(256);
entry.Property(x => x.MetadataJson).HasColumnType("jsonb");
entry.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
entry.HasIndex(x => x.WorkspaceId);
entry.HasIndex(x => x.ContentItemId);
entry.HasIndex(x => new { x.ContentItemId, x.CreatedAt });
});
return modelBuilder; return modelBuilder;
} }
} }

View File

@@ -1,4 +1,5 @@
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Services;
namespace Socialize.Api.Modules.ContentItems; namespace Socialize.Api.Modules.ContentItems;
@@ -7,6 +8,8 @@ public static class DependencyInjection
public static WebApplicationBuilder AddContentItemsModule( public static WebApplicationBuilder AddContentItemsModule(
this WebApplicationBuilder builder) this WebApplicationBuilder builder)
{ {
builder.Services.AddScoped<IContentItemActivityWriter, ContentItemActivityWriter>();
return builder; return builder;
} }
} }

View File

@@ -2,16 +2,18 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.ContentItems.Handlers; namespace Socialize.Api.Modules.ContentItems.Handlers;
public record CreateContentItemRequest( public record CreateContentItemRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
Guid ProjectId, Guid CampaignId,
string Title, string Title,
string PublicationMessage, string PublicationMessage,
string PublicationTargets, string PublicationTargets,
@@ -25,7 +27,7 @@ public class CreateContentItemRequestValidator
{ {
RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty(); RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty(); RuleFor(x => x.CampaignId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256); RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000); RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512); RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
@@ -36,6 +38,7 @@ public class CreateContentItemRequestValidator
public class CreateContentItemHandler( public class CreateContentItemHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRequest, ContentItemDto> : Endpoint<CreateContentItemRequest, ContentItemDto>
{ {
@@ -47,7 +50,7 @@ public class CreateContentItemHandler(
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct) public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{ {
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId)) if (!await accessScopeService.CanContributeToCampaignAsync(User, request.WorkspaceId, request.ClientId, request.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -75,16 +78,16 @@ public class CreateContentItemHandler(
return; return;
} }
bool projectExists = await dbContext.Projects bool campaignExists = await dbContext.Campaigns
.AnyAsync( .AnyAsync(
project => project.Id == request.ProjectId && campaign => campaign.Id == request.CampaignId &&
project.WorkspaceId == request.WorkspaceId && campaign.WorkspaceId == request.WorkspaceId &&
project.ClientId == request.ClientId, campaign.ClientId == request.ClientId,
ct); ct);
if (!projectExists) if (!campaignExists)
{ {
AddError(request => request.ProjectId, "The selected project does not belong to the selected client."); AddError(request => request.CampaignId, "The selected campaign does not belong to the selected client.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return; return;
} }
@@ -94,7 +97,7 @@ public class CreateContentItemHandler(
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId, WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId, ClientId = request.ClientId,
ProjectId = request.ProjectId, CampaignId = request.CampaignId,
Title = request.Title.Trim(), Title = request.Title.Trim(),
PublicationMessage = request.PublicationMessage.Trim(), PublicationMessage = request.PublicationMessage.Trim(),
PublicationTargets = request.PublicationTargets.Trim(), PublicationTargets = request.PublicationTargets.Trim(),
@@ -121,6 +124,26 @@ public class CreateContentItemHandler(
}); });
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
item.WorkspaceId,
item.Id,
"content-item.created",
"ContentItem",
item.Id,
$"Content item {item.Title} was created.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
status = item.Status,
revisionLabel = item.CurrentRevisionLabel,
dueDate = item.DueDate,
publicationTargets = item.PublicationTargets,
hashtags = item.Hashtags,
})),
ct);
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
item.WorkspaceId, item.WorkspaceId,
@@ -138,7 +161,7 @@ public class CreateContentItemHandler(
item.Id, item.Id,
item.WorkspaceId, item.WorkspaceId,
item.ClientId, item.ClientId,
item.ProjectId, item.CampaignId,
item.Title, item.Title,
item.PublicationMessage, item.PublicationMessage,
item.PublicationTargets, item.PublicationTargets,

View File

@@ -2,8 +2,10 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.ContentItems.Handlers; namespace Socialize.Api.Modules.ContentItems.Handlers;
@@ -12,7 +14,8 @@ public record CreateContentItemRevisionRequest(
string PublicationMessage, string PublicationMessage,
string PublicationTargets, string PublicationTargets,
string? Hashtags, string? Hashtags,
string? ChangeSummary); string? ChangeSummary,
DateTimeOffset? DueDate);
public class CreateContentItemRevisionRequestValidator public class CreateContentItemRevisionRequestValidator
: Validator<CreateContentItemRevisionRequest> : Validator<CreateContentItemRevisionRequest>
@@ -30,6 +33,7 @@ public class CreateContentItemRevisionRequestValidator
public class CreateContentItemRevisionHandler( public class CreateContentItemRevisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto> : Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
{ {
@@ -50,7 +54,7 @@ public class CreateContentItemRevisionHandler(
return; return;
} }
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanContributeToCampaignAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -58,23 +62,24 @@ public class CreateContentItemRevisionHandler(
int revisionNumber = item.CurrentRevisionNumber + 1; int revisionNumber = item.CurrentRevisionNumber + 1;
string revisionLabel = $"v{revisionNumber}"; string revisionLabel = $"v{revisionNumber}";
string previousTitle = item.Title;
string previousPublicationMessage = item.PublicationMessage;
string previousPublicationTargets = item.PublicationTargets;
string? previousHashtags = item.Hashtags;
DateTimeOffset? previousDueDate = item.DueDate;
string newTitle = request.Title.Trim();
string newPublicationMessage = request.PublicationMessage.Trim();
string newPublicationTargets = request.PublicationTargets.Trim();
string? newHashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim();
item.Title = request.Title.Trim(); item.Title = newTitle;
item.PublicationMessage = request.PublicationMessage.Trim(); item.PublicationMessage = newPublicationMessage;
item.PublicationTargets = request.PublicationTargets.Trim(); item.PublicationTargets = newPublicationTargets;
item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(); item.Hashtags = newHashtags;
item.DueDate = request.DueDate;
item.CurrentRevisionNumber = revisionNumber; item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel; item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new() ContentItemRevision revision = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -93,6 +98,32 @@ public class CreateContentItemRevisionHandler(
dbContext.ContentItemRevisions.Add(revision); dbContext.ContentItemRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
List<object> changedFields = [];
AddChangedField(changedFields, "title", previousTitle, item.Title);
AddChangedField(changedFields, "publicationMessage", previousPublicationMessage, item.PublicationMessage);
AddChangedField(changedFields, "publicationTargets", previousPublicationTargets, item.PublicationTargets);
AddChangedField(changedFields, "hashtags", previousHashtags, item.Hashtags);
AddChangedField(changedFields, "dueDate", previousDueDate, item.DueDate);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
item.WorkspaceId,
item.Id,
"content-item.revision.created",
"ContentItemRevision",
revision.Id,
$"Revision {revisionLabel} was created for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
revisionLabel,
revisionNumber,
changeSummary = revision.ChangeSummary,
changedFields,
})),
ct);
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
item.WorkspaceId, item.WorkspaceId,
@@ -121,4 +152,19 @@ public class CreateContentItemRevisionHandler(
await SendAsync(dto, StatusCodes.Status201Created, ct); await SendAsync(dto, StatusCodes.Status201Created, ct);
} }
private static void AddChangedField<T>(List<object> changedFields, string field, T oldValue, T newValue)
{
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
{
return;
}
changedFields.Add(new
{
field,
oldValue,
newValue,
});
}
} }

View File

@@ -10,7 +10,7 @@ public record ContentItemDetailDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
Guid ProjectId, Guid CampaignId,
string Title, string Title,
string PublicationMessage, string PublicationMessage,
string PublicationTargets, string PublicationTargets,
@@ -42,7 +42,7 @@ public class GetContentItemHandler(
candidate.Id, candidate.Id,
candidate.WorkspaceId, candidate.WorkspaceId,
candidate.ClientId, candidate.ClientId,
candidate.ProjectId, candidate.CampaignId,
candidate.Title, candidate.Title,
candidate.PublicationMessage, candidate.PublicationMessage,
candidate.PublicationTargets, candidate.PublicationTargets,
@@ -60,7 +60,7 @@ public class GetContentItemHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -0,0 +1,72 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record ContentItemActivityEntryDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
string EventType,
string EntityType,
Guid EntityId,
string Summary,
Guid? ActorUserId,
string? ActorEmail,
string? MetadataJson,
DateTimeOffset CreatedAt);
public class GetContentItemActivityHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<ContentItemActivityEntryDto>>
{
public override void Configure()
{
Get("/api/content-items/{id}/activity");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
List<ContentItemActivityEntryDto> entries = await dbContext.ContentItemActivityEntries
.Where(entry => entry.ContentItemId == item.Id)
.OrderByDescending(entry => entry.CreatedAt)
.Take(200)
.Select(entry => new ContentItemActivityEntryDto(
entry.Id,
entry.WorkspaceId,
entry.ContentItemId,
entry.EventType,
entry.EntityType,
entry.EntityId,
entry.Summary,
entry.ActorUserId,
entry.ActorEmail,
entry.MetadataJson,
entry.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(entries, ct);
}
}

View File

@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
return; return;
} }
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;

View File

@@ -6,13 +6,13 @@ using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers; namespace Socialize.Api.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId); public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? CampaignId);
public record ContentItemDto( public record ContentItemDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
Guid ProjectId, Guid CampaignId,
string Title, string Title,
string PublicationMessage, string PublicationMessage,
string PublicationTargets, string PublicationTargets,
@@ -39,9 +39,9 @@ public class GetContentItemsHandler(
if (!accessScopeService.IsManager(User)) if (!accessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds(); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds(); IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId)); query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
@@ -50,9 +50,9 @@ public class GetContentItemsHandler(
query = query.Where(item => clientScopeIds.Contains(item.ClientId)); query = query.Where(item => clientScopeIds.Contains(item.ClientId));
} }
if (projectScopeIds.Count > 0) if (campaignScopeIds.Count > 0)
{ {
query = query.Where(item => projectScopeIds.Contains(item.ProjectId)); query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
} }
} }
@@ -61,9 +61,9 @@ public class GetContentItemsHandler(
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value); query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
} }
if (request.ProjectId.HasValue) if (request.CampaignId.HasValue)
{ {
query = query.Where(item => item.ProjectId == request.ProjectId.Value); query = query.Where(item => item.CampaignId == request.CampaignId.Value);
} }
if (request.ClientId.HasValue) if (request.ClientId.HasValue)
@@ -78,7 +78,7 @@ public class GetContentItemsHandler(
item.Id, item.Id,
item.WorkspaceId, item.WorkspaceId,
item.ClientId, item.ClientId,
item.ProjectId, item.CampaignId,
item.Title, item.Title,
item.PublicationMessage, item.PublicationMessage,
item.PublicationTargets, item.PublicationTargets,

View File

@@ -2,8 +2,12 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.ContentItems.Handlers; namespace Socialize.Api.Modules.ContentItems.Handlers;
@@ -21,24 +25,19 @@ public class UpdateContentItemStatusRequestValidator
public class UpdateContentItemStatusHandler( public class UpdateContentItemStatusHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto> : Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{ {
private static readonly HashSet<string> AllowedStatuses = private static readonly HashSet<string> AllowedStatuses =
[ [
"Draft", "Draft",
"In internal review", "In production",
"Changes requested internally", "In approval",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"Approved", "Approved",
"Rejected", "Scheduled",
"Ready to publish",
"Published", "Published",
"Archived",
]; ];
public override void Configure() public override void Configure()
@@ -58,7 +57,7 @@ public class UpdateContentItemStatusHandler(
return; return;
} }
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId)) if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct))
{ {
await SendForbiddenAsync(ct); await SendForbiddenAsync(ct);
return; return;
@@ -72,9 +71,87 @@ public class UpdateContentItemStatusHandler(
return; return;
} }
item.Status = normalizedStatus; Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync(
item,
workspace,
User.GetUserId(),
ct);
if (!startResult.Succeeded)
{
AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) &&
ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode))
{
if (workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct);
if (!hasCompletedWorkflow)
{
AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else
{
bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync(
approval => approval.ContentItemId == item.Id &&
approval.WorkspaceId == item.WorkspaceId &&
approval.State == "Approved" &&
approval.CompletedAt.HasValue,
ct);
if (!hasApprovedDecision)
{
AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
}
string previousStatus = item.Status;
if (item.Status != "In approval" || normalizedStatus != "In approval")
{
item.Status = normalizedStatus;
}
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
if (previousStatus != item.Status)
{
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
item.WorkspaceId,
item.Id,
"content-item.status.updated",
"ContentItem",
item.Id,
$"Status changed from {previousStatus} to {item.Status} for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
oldValue = previousStatus,
newValue = item.Status,
})),
ct);
}
await notificationEventWriter.WriteAsync( await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel( new NotificationEventWriteModel(
item.WorkspaceId, item.WorkspaceId,
@@ -92,7 +169,7 @@ public class UpdateContentItemStatusHandler(
item.Id, item.Id,
item.WorkspaceId, item.WorkspaceId,
item.ClientId, item.ClientId,
item.ProjectId, item.CampaignId,
item.Title, item.Title,
item.PublicationMessage, item.PublicationMessage,
item.PublicationTargets, item.PublicationTargets,

View File

@@ -0,0 +1,31 @@
using Socialize.Api.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Services;
public class ContentItemActivityWriter(
AppDbContext dbContext)
: IContentItemActivityWriter
{
public async Task WriteAsync(ContentItemActivityWriteModel model, CancellationToken cancellationToken = default)
{
ContentItemActivityEntry entry = new()
{
Id = Guid.NewGuid(),
WorkspaceId = model.WorkspaceId,
ContentItemId = model.ContentItemId,
EventType = model.EventType,
EntityType = model.EntityType,
EntityId = model.EntityId,
Summary = model.Summary,
ActorUserId = model.ActorUserId,
ActorEmail = model.ActorEmail,
MetadataJson = model.MetadataJson,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItemActivityEntries.Add(entry);
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -7,8 +7,8 @@ public record FeedbackContextDto(
string? WorkspaceName, string? WorkspaceName,
Guid? ClientId, Guid? ClientId,
string? ClientName, string? ClientName,
Guid? ProjectId, Guid? CampaignId,
string? ProjectName, string? CampaignName,
Guid? ContentItemId, Guid? ContentItemId,
string? ContentItemTitle); string? ContentItemTitle);
@@ -82,8 +82,8 @@ public static class FeedbackDtoMapper
report.WorkspaceName, report.WorkspaceName,
report.ClientId, report.ClientId,
report.ClientName, report.ClientName,
report.ProjectId, report.CampaignId,
report.ProjectName, report.CampaignName,
report.ContentItemId, report.ContentItemId,
report.ContentItemTitle), report.ContentItemTitle),
report.Screenshot is null report.Screenshot is null

View File

@@ -20,7 +20,7 @@ public static class FeedbackModelConfiguration
feedback.Property(x => x.AppVersion).HasMaxLength(128); feedback.Property(x => x.AppVersion).HasMaxLength(128);
feedback.Property(x => x.WorkspaceName).HasMaxLength(256); feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
feedback.Property(x => x.ClientName).HasMaxLength(256); feedback.Property(x => x.ClientName).HasMaxLength(256);
feedback.Property(x => x.ProjectName).HasMaxLength(256); feedback.Property(x => x.CampaignName).HasMaxLength(256);
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256); feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
feedback.Property(x => x.CancellationReason).HasMaxLength(2000); feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");

View File

@@ -18,8 +18,8 @@ public class FeedbackReport
public string? WorkspaceName { get; set; } public string? WorkspaceName { get; set; }
public Guid? ClientId { get; set; } public Guid? ClientId { get; set; }
public string? ClientName { get; set; } public string? ClientName { get; set; }
public Guid? ProjectId { get; set; } public Guid? CampaignId { get; set; }
public string? ProjectName { get; set; } public string? CampaignName { get; set; }
public Guid? ContentItemId { get; set; } public Guid? ContentItemId { get; set; }
public string? ContentItemTitle { get; set; } public string? ContentItemTitle { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }

Some files were not shown because too many files have changed in this diff Show More