92 Commits

Author SHA1 Message Date
986c7efea6 feat: close preprod observability loop
All checks were successful
deploy-socialize / image (push) Successful in 1m2s
deploy-socialize / deploy (push) Successful in 38s
2026-05-08 15:48:56 -04:00
8bcff96821 feat: add preprod observability foundation 2026-05-08 15:45:31 -04:00
1ca6ab7117 feat: centralize frontend Vuetify styling
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 13:45:42 -04:00
e81c9f42c9 fix: scope organization access by membership
All checks were successful
deploy-socialize / image (push) Successful in 54s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 09:09:16 -04:00
c527011646 feat: add release digest controls
All checks were successful
deploy-socialize / image (push) Successful in 1m13s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 08:30:47 -04:00
0b7edb1b7f chore: add release note generator
All checks were successful
deploy-socialize / image (push) Successful in 26s
deploy-socialize / deploy (push) Successful in 18s
2026-05-08 00:38:50 -04:00
dcfdce1ec6 Simplify release notes workflow
Some checks failed
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Has been cancelled
2026-05-08 00:37:14 -04:00
2eb54b9228 fix: normalize release commit timestamps
All checks were successful
deploy-socialize / image (push) Successful in 51s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 21:45:42 -04:00
9c011f1a1e feat: import release commits from repository api
All checks were successful
deploy-socialize / image (push) Successful in 1m12s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 21:38:57 -04:00
b6eb348605 feat: add release communications
All checks were successful
deploy-socialize / image (push) Successful in 1m12s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 21:04:29 -04:00
7a8a0a44bf feat: localize membership tier display
All checks were successful
deploy-socialize / image (push) Successful in 1m11s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 20:43:08 -04:00
6d92119c9c feat: add database backed membership tiers
All checks were successful
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 20:29:53 -04:00
db16e79d9f feat: add organization onboarding
All checks were successful
deploy-socialize / image (push) Successful in 1m8s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 20:07:50 -04:00
4aaa1a7f90 refactor: use vuetify form controls 2026-05-07 19:38:51 -04:00
6ac05e1a10 refactor: simplify frontend theme setup 2026-05-07 16:35:47 -04:00
9768a37252 fix(frontend): align TypeScript with OpenAPI tooling
All checks were successful
deploy-socialize / image (push) Successful in 59s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 15:59:10 -04:00
98c76a7d88 chore: group database diagram tables by module
Some checks failed
deploy-socialize / image (push) Failing after 24s
deploy-socialize / deploy (push) Has been skipped
2026-05-07 15:51:47 -04:00
49e2ca1774 fix(backend): add missing domain foreign keys
Some checks failed
deploy-socialize / image (push) Failing after 44s
deploy-socialize / deploy (push) Has been skipped
2026-05-07 15:48:12 -04:00
e9fb1c5ee0 chore: add database diagram generator
Some checks failed
deploy-socialize / image (push) Failing after 26s
deploy-socialize / deploy (push) Has been skipped
2026-05-07 14:40:15 -04:00
57abe57bc7 fix: confirm email changes and enforce clean backend build
Some checks failed
deploy-socialize / deploy (push) Has been cancelled
deploy-socialize / image (push) Has been cancelled
2026-05-07 14:39:22 -04:00
9022fa7d93 fix(backend): make API types internal 2026-05-07 14:06:37 -04:00
d1621ecb36 refactor(backend): rename registration classes 2026-05-07 14:02:55 -04:00
6e417312f9 chore(backend): add explicit test data seed command
All checks were successful
deploy-socialize / image (push) Successful in 53s
deploy-socialize / deploy (push) Successful in 21s
2026-05-07 13:43:53 -04:00
918136aae2 fix(frontend): update router guard API
All checks were successful
deploy-socialize / image (push) Successful in 53s
deploy-socialize / deploy (push) Successful in 18s
2026-05-07 12:34:32 -04:00
0521d91240 fix(frontend): update favicon assets
All checks were successful
deploy-socialize / image (push) Successful in 1m27s
deploy-socialize / deploy (push) Successful in 19s
2026-05-07 12:31:55 -04:00
c18a223759 chore(backend): refresh initial migration timestamp 2026-05-07 12:31:55 -04:00
298c46de7c fix(frontend): remove BOM from development env 2026-05-07 12:31:55 -04:00
2d22fd6e04 chore(frontend): migrate Tailwind to Vite plugin 2026-05-07 12:31:55 -04:00
ef323c291f chore(cd): hardening of env settings
All checks were successful
deploy-socialize / image (push) Successful in 28s
deploy-socialize / deploy (push) Successful in 15s
2026-05-06 21:25:11 -04:00
4eb0fbc22b fix: avoid feedback screenshot concurrency save
All checks were successful
deploy-socialize / image (push) Successful in 33s
deploy-socialize / deploy (push) Successful in 19s
2026-05-06 20:14:22 -04:00
afe22949c5 ci: run deploy job on ubuntu runner
All checks were successful
deploy-socialize / image (push) Successful in 29s
deploy-socialize / deploy (push) Successful in 23s
2026-05-06 16:20:59 -04:00
ebb87b286f ci: checkout deploy compose artifact
Some checks failed
deploy-socialize / image (push) Successful in 32s
deploy-socialize / deploy (push) Failing after 2s
2026-05-06 16:18:40 -04:00
f1da3a44de ci: sync production compose file
Some checks failed
deploy-socialize / image (push) Successful in 29s
deploy-socialize / deploy (push) Failing after 7s
2026-05-06 16:16:55 -04:00
419dbf0185 ci: align compose database host
All checks were successful
deploy-socialize / image (push) Successful in 34s
deploy-socialize / deploy (push) Successful in 14s
2026-05-06 15:56:29 -04:00
909ae6f092 ci: export backend deployment environment
All checks were successful
deploy-socialize / image (push) Successful in 34s
deploy-socialize / deploy (push) Successful in 14s
2026-05-06 15:49:28 -04:00
a97ff2dc38 fix: add verification resend flow
All checks were successful
deploy-socialize / image (push) Successful in 1m21s
deploy-socialize / deploy (push) Successful in 14s
2026-05-06 15:43:25 -04:00
7a862a202a fix: normalize Resend API key configuration
All checks were successful
deploy-socialize / image (push) Successful in 57s
deploy-socialize / deploy (push) Successful in 23s
2026-05-06 15:36:49 -04:00
1ae3188d34 chore: configure preprod email secrets
All checks were successful
deploy-socialize / image (push) Successful in 52s
deploy-socialize / deploy (push) Successful in 13s
2026-05-06 15:24:17 -04:00
fb7811c469 ci: quote deploy environment secrets
All checks were successful
deploy-socialize / image (push) Successful in 27s
deploy-socialize / deploy (push) Successful in 13s
2026-05-06 15:08:53 -04:00
0a6d730ca0 chore: source compose database password from secrets
Some checks failed
deploy-socialize / image (push) Successful in 30s
deploy-socialize / deploy (push) Failing after 6s
2026-05-06 15:05:10 -04:00
d2d3bee975 ci: remove repository hygiene check 2026-05-06 14:51:43 -04:00
78de068cd1 chore: ignore AI agent local state 2026-05-06 14:50:06 -04:00
1965dc2c9e docs: remove archived legacy material
All checks were successful
deploy-socialize / image (push) Successful in 1m24s
deploy-socialize / deploy (push) Successful in 9s
2026-05-06 14:40:28 -04:00
f0d635ef21 chore: remove legacy Hutopy assets 2026-05-06 14:36:23 -04:00
d59d667796 chore: remove legacy deployment domains 2026-05-06 14:33:34 -04:00
5c0e40db7e feat: centralize frontend branding 2026-05-06 14:27:09 -04:00
dc9a980958 fix: frontend API base URL
All checks were successful
deploy-socialize / image (push) Successful in 1m26s
deploy-socialize / deploy (push) Successful in 8s
2026-05-06 10:56:59 -04:00
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 / test (push) Failing after 28s
deploy-socialize / image (push) Has been skipped
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
577 changed files with 67246 additions and 12962 deletions

View File

@@ -0,0 +1,75 @@
name: deploy-socialize
on:
push:
branches:
- main
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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=/ \
-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: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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 }}
SOCIALIZE_IMAGE_TAG: ${{ gitea.sha }}
run: |
: "${SOCIALIZE_IMAGE_TAG:?SOCIALIZE_IMAGE_TAG is required}"
mkdir -p ~/.ssh
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
write_env_value() {
key="$1"
value="$2"
escaped_value="$(printf '%s' "$value" | sed "s/'/'\\\\''/g")"
printf "%s='%s'\n" "$key" "$escaped_value"
}
deploy_env="$(mktemp)"
{
write_env_value SOCIALIZE_IMAGE_TAG "$SOCIALIZE_IMAGE_TAG"
} > "$deploy_env"
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$deploy_env" "$DEPLOY_USER@$DEPLOY_HOST:/srv/prod/socialize/.deploy.env"
rm -f "$deploy_env"
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new deploy/compose.yml "$DEPLOY_USER@$DEPLOY_HOST:/srv/prod/socialize/compose.yml"
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
'test -r /etc/socialize/socialize.env && 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 }}

13
.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
@@ -30,6 +34,7 @@ dist/
.vite/ .vite/
# Local environment files # Local environment files
.env
*.local *.local
.env.local .env.local
.env.*.local .env.*.local
@@ -38,5 +43,11 @@ App_Data/
# Local SSL certificates # Local SSL certificates
*.pem *.pem
# Ai # AI agent local state
.agents
.agents/
.codex .codex
.codex/
# Generated local artifacts
.artifacts/

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.
@@ -76,6 +76,47 @@ http://localhost:8080
http://<this-machine-lan-ip>:8080 http://<this-machine-lan-ip>:8080
``` ```
For preprod deployment, configure the `POSTGRES_PASSWORD`, `RESEND_API_KEY`,
`RESEND_FROM_EMAIL`, and `JWT_SIGNING_KEY` Gitea secrets.
The deploy workflow writes the remote `.env` file and syncs `deploy/compose.yml`
before running the server deploy script.
Use the raw Resend API key value for `RESEND_API_KEY`, without a `Bearer ` prefix.
## Preprod Observability
The optional observability overlay runs a self-hosted Grafana stack for preproduction:
- Grafana `13.0.1`: dashboards
- Prometheus `v3.11.3`: metrics and local alert rules
- Loki `3.7.1`: Docker/container logs
- Tempo `2.10.3`: traces
- Grafana Alloy `v1.16.0`: OTLP receiver and Docker log collector
Start the app with observability:
```bash
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml up -d
```
Grafana is exposed at:
```txt
http://127.0.0.1:3000
```
Default credentials are `admin` / `admin` unless `GRAFANA_ADMIN_USER` and
`GRAFANA_ADMIN_PASSWORD` are set. Set `GRAFANA_HTTP_BIND=0.0.0.0` only when the
preprod network boundary is trusted or protected by a reverse proxy/VPN.
Set a non-default `GRAFANA_ADMIN_PASSWORD` before exposing Grafana outside the
host. Prometheus alert rules are provisioned under
`deploy/observability/prometheus/rules/`; notification delivery is intentionally
left to the preprod operations environment.
Set `ALERTMANAGER_WEBHOOK_URL` to route alerts to a private notification endpoint.
See `docs/OPERATIONS/observability-runbook.md` for bring-up, alert triage, and
the optional protected Caddy configuration for Grafana.
## Solution ## Solution
```bash ```bash
@@ -90,6 +131,24 @@ cd frontend
npm run build npm run build
``` ```
## Database Diagram
Start PostgreSQL, then generate a local schema diagram:
```bash
./scripts/generate-db-diagram.sh
```
The script writes an HTML viewer, SVG, PNG, and Graphviz source under:
```txt
.artifacts/db-diagrams/
```
Use `DATABASE_URL`, `PGPASSWORD`, or `~/.pgpass` to provide local database credentials.
When using the repository infrastructure script, the diagram script can read from the
running `socialize-postgres` container directly.
## Agentic Workflow ## Agentic Workflow
Start here: Start here:

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

@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Observability;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@@ -11,7 +12,7 @@ using Microsoft.IdentityModel.Tokens;
namespace Socialize; namespace Socialize;
public static class DependencyInjection internal static class ApplicationRegistration
{ {
public static IServiceCollection AddWebServices(this IServiceCollection services) public static IServiceCollection AddWebServices(this IServiceCollection services)
{ {
@@ -20,7 +21,10 @@ public static class DependencyInjection
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddHealthChecks() services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(); .AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"])
.AddDbContextCheck<AppDbContext>("postgres", tags: ["ready"])
.AddCheck<LocalBlobStorageHealthCheck>("local_blob_storage", tags: ["ready"])
.AddCheck<EmailerConfigurationHealthCheck>("emailer_configuration", tags: ["ready"]);
services.AddHttpClient(); services.AddHttpClient();
services.AddScoped<AccessScopeService>(); services.AddScoped<AccessScopeService>();
@@ -70,7 +74,6 @@ public static class DependencyInjection
{ {
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
{ {
jwtBearerOptions.Authority = "https://hutopy.com";
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
@@ -79,7 +82,7 @@ public static class DependencyInjection
ValidAudience = authJwt["Audience"], ValidAudience = authJwt["Audience"],
ValidateLifetime = true, ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ?? IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
throw new ArgumentNullException("The Jwt Key is missing."))) throw new InvalidOperationException("Authentication:Jwt:Key is required.")))
}; };
}); });
} }
@@ -90,9 +93,9 @@ public static class DependencyInjection
authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options => authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authGoogle["ClientId"] ?? options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing."); throw new InvalidOperationException("Authentication:Google:ClientId is required.");
options.ClientSecret = authGoogle["ClientSecret"] ?? options.ClientSecret = authGoogle["ClientSecret"] ??
throw new ArgumentNullException("The Google ClientSecret is missing."); throw new InvalidOperationException("Authentication:Google:ClientSecret is required.");
}); });
} }
@@ -102,9 +105,9 @@ public static class DependencyInjection
authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options => authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authFacebook["ClientId"] ?? options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientId is required.");
options.ClientSecret = authFacebook["ClientSecret"] ?? options.ClientSecret = authFacebook["ClientSecret"] ??
throw new ArgumentNullException("The Facebook ClientSecret is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientSecret is required.");
}); });
} }

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Common.Domain; namespace Socialize.Api.Common.Domain;
public abstract class Entity internal abstract class Entity
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid CreatedBy { get; init; } public Guid CreatedBy { get; init; }

View File

@@ -2,51 +2,76 @@ 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.ReleaseCommunications.Data;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data; namespace Socialize.Api.Data;
public class AppDbContext( internal 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<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
public DbSet<OrganizationMembershipTierTranslation> OrganizationMembershipTierTranslations =>
Set<OrganizationMembershipTierTranslation>();
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>();
public DbSet<ReleaseUpdate> ReleaseUpdates => Set<ReleaseUpdate>();
public DbSet<ReleaseUpdateReadReceipt> ReleaseUpdateReadReceipts => Set<ReleaseUpdateReadReceipt>();
public DbSet<ReleaseCommit> ReleaseCommits => Set<ReleaseCommit>();
public DbSet<ReleaseUpdateEmailDigestReceipt> ReleaseUpdateEmailDigestReceipts => Set<ReleaseUpdateEmailDigestReceipt>();
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();
builder.ConfigureReleaseCommunicationsModule();
} }
} }

View File

@@ -1,5 +0,0 @@
<wpf:ResourceDictionary xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xml:space="preserve">
<s:Boolean x:Key="/Default/UserDictionary/Words/=hutopy/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration; namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
public sealed class LocalBlobStorageOptions internal sealed class LocalBlobStorageOptions
{ {
public const string SectionName = "LocalBlobStorage"; public const string SectionName = "LocalBlobStorage";

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class CommonFileNames internal static class CommonFileNames
{ {
public const string ProfilePicture = "profilePicture"; public const string ProfilePicture = "profilePicture";
public const string LogoPicture = "logoPicture"; public const string LogoPicture = "logoPicture";

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

@@ -2,7 +2,7 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class ContentTypes internal static class ContentTypes
{ {
private const string ImagePng = "image/png"; private const string ImagePng = "image/png";
private const string ImageJpeg = "image/jpeg"; private const string ImageJpeg = "image/jpeg";
@@ -39,6 +39,6 @@ public static class ContentTypes
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags // Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer); string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>"); return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
} }
} }

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage internal interface IBlobStorage
{ {
/// <summary> /// <summary>
/// Upload a file to blob storage. /// Upload a file to blob storage.

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class SubDirectoryNames internal static class SubDirectoryNames
{ {
public const string Profile = "profile"; public const string Profile = "profile";
public const string Contents = "contents"; public const string Contents = "contents";

View File

@@ -1,19 +1,29 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Socialize.Api.Infrastructure.BlobStorage.Configuration; using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.BlobStorage.Contracts; using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Observability;
namespace Socialize.Api.Infrastructure.BlobStorage.Services; namespace Socialize.Api.Infrastructure.BlobStorage.Services;
public sealed class LocalBlobStorage( internal sealed class LocalBlobStorage(
IWebHostEnvironment environment, IWebHostEnvironment environment,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IOptions<LocalBlobStorageOptions> options, IOptions<LocalBlobStorageOptions> options,
ILogger<LocalBlobStorage> logger) ILogger<LocalBlobStorage> logger,
SocializeMetrics metrics)
: IBlobStorage : IBlobStorage
{ {
private const long MaxUploadSize = 10 * 1024 * 1024; private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type"; private const string ContentTypeMetadataSuffix = ".content-type";
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
private static readonly Action<ILogger, string, string, string, string, Exception?> LogUploadedFile =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(UploadFileAsync)),
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]");
private readonly LocalBlobStorageOptions _options = options.Value; private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> UploadFileAsync( public async Task<string> UploadFileAsync(
@@ -23,37 +33,51 @@ public sealed class LocalBlobStorage(
string contentType, string contentType,
CancellationToken ct = default) CancellationToken ct = default)
{ {
stream.Position = 0; try
if (stream.Length > MaxUploadSize)
{ {
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize); stream.Position = 0;
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
}
if (!ContentTypes.IsAllowed(contentType, stream)) if (stream.Length > MaxUploadSize)
{
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
}
if (!ContentTypes.IsAllowed(contentType, stream))
{
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
throw new InvalidOperationException("Unsupported file type.");
}
string relativePath = GetSafeRelativePath(containerName, blobName);
string filePath = Path.Combine(GetRootPath(), relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
await using FileStream fileStream = File.Create(filePath);
await stream.CopyToAsync(fileStream, ct);
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath);
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
metrics.RecordBlobStorageOperation("upload", true);
return fileUri;
}
catch (InvalidOperationException)
{ {
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType); metrics.RecordBlobStorageOperation("upload", false);
throw new InvalidOperationException("Unsupported file type."); throw;
}
catch (IOException)
{
metrics.RecordBlobStorageOperation("upload", false);
throw;
}
catch (UnauthorizedAccessException)
{
metrics.RecordBlobStorageOperation("upload", false);
throw;
} }
string relativePath = GetSafeRelativePath(containerName, blobName);
string filePath = Path.Combine(GetRootPath(), relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
await using FileStream fileStream = File.Create(filePath);
await stream.CopyToAsync(fileStream, ct);
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath);
logger.LogInformation(
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
blobName,
containerName,
contentType,
fileUri);
return fileUri;
} }
public async Task<MemoryStream> DownloadFileAsync( public async Task<MemoryStream> DownloadFileAsync(
@@ -61,19 +85,43 @@ public sealed class LocalBlobStorage(
string blobName, string blobName,
CancellationToken ct = default) CancellationToken ct = default)
{ {
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName)); try
if (!File.Exists(filePath))
{ {
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName); string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
}
MemoryStream memoryStream = new();
await using FileStream fileStream = File.OpenRead(filePath);
await fileStream.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0;
metrics.RecordBlobStorageOperation("download", true);
return memoryStream;
}
catch (InvalidOperationException)
{
metrics.RecordBlobStorageOperation("download", false);
throw;
}
catch (FileNotFoundException)
{
metrics.RecordBlobStorageOperation("download", false);
throw;
}
catch (IOException)
{
metrics.RecordBlobStorageOperation("download", false);
throw;
}
catch (UnauthorizedAccessException)
{
metrics.RecordBlobStorageOperation("download", false);
throw;
} }
MemoryStream memoryStream = new();
await using FileStream fileStream = File.OpenRead(filePath);
await fileStream.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0;
return memoryStream;
} }
internal string GetRootPath() internal string GetRootPath()
@@ -106,7 +154,7 @@ public sealed class LocalBlobStorage(
throw new InvalidOperationException("Blob storage: Blob paths must be relative."); throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
} }
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])]; string[] pathParts = [containerName, .. blobName.Split(PathSeparators)];
if (pathParts.Any(part => part is "" or "." or "..")) if (pathParts.Any(part => part is "" or "." or ".."))
{ {
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments."); throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
@@ -135,7 +183,7 @@ public sealed class LocalBlobStorage(
? "/api/storage" ? "/api/storage"
: requestPath.Trim(); : requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) return normalized.StartsWith('/')
? normalized.TrimEnd('/') ? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}"; : $"/{normalized.TrimEnd('/')}";
} }

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.Configuration; namespace Socialize.Api.Infrastructure.Configuration;
public class WebsiteOptions internal class WebsiteOptions
{ {
public const string SectionName = "Website"; public const string SectionName = "Website";

View File

@@ -1,8 +0,0 @@
namespace Socialize.Api.Infrastructure.Development;
public record DevelopmentSeedOptions
{
public const string SectionName = "DevelopmentSeed";
public bool Enabled { get; init; } = true;
}

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.Emailer.Configuration; namespace Socialize.Api.Infrastructure.Emailer.Configuration;
public class EmailerOptions internal class EmailerOptions
{ {
public const string ConfigurationSection = "Emailer"; public const string ConfigurationSection = "Emailer";

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.Emailer.Contracts; namespace Socialize.Api.Infrastructure.Emailer.Contracts;
public interface IEmailSender internal interface IEmailSender
{ {
Task SendEmailAsync(string email, string subject, string message); Task SendEmailAsync(string email, string subject, string message);
} }

View File

@@ -1,22 +1,24 @@
using Socialize.Api.Infrastructure.Emailer.Contracts; using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Observability;
namespace Socialize.Api.Infrastructure.Emailer.Services; namespace Socialize.Api.Infrastructure.Emailer.Services;
public class LoggerEmailSender(ILogger<IEmailSender> logger) internal class LoggerEmailSender(
ILogger<IEmailSender> logger,
SocializeMetrics metrics)
: IEmailSender : IEmailSender
{ {
public async Task SendEmailAsync(string email, string subject, string message) private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(SendEmailAsync)),
"Development email to {Email} with subject {Subject}:{NewLine}{Message}");
public Task SendEmailAsync(string email, string subject, string message)
{ {
try LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
{ metrics.RecordEmailDelivery("logger", true);
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
await Task.Delay(1000); return Task.CompletedTask;
logger.LogInformation("Email sent successfully to {Email}", email);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email to {Email}", email);
throw;
}
} }
} }

View File

@@ -5,7 +5,7 @@ using PostmarkDotNet;
namespace Socialize.Api.Infrastructure.Emailer.Services; namespace Socialize.Api.Infrastructure.Emailer.Services;
public class PostmarkEmailSender : IEmailSender internal class PostmarkEmailSender : IEmailSender
{ {
private readonly PostmarkClient _client; private readonly PostmarkClient _client;
private readonly EmailerOptions _options; private readonly EmailerOptions _options;

View File

@@ -3,44 +3,95 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Socialize.Api.Infrastructure.Emailer.Configuration; using Socialize.Api.Infrastructure.Emailer.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts; using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Observability;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Socialize.Api.Infrastructure.Emailer.Services; namespace Socialize.Api.Infrastructure.Emailer.Services;
public class ResendEmailSender : IEmailSender internal class ResendEmailSender : IEmailSender
{ {
private static readonly Uri EndpointUri = new("https://api.resend.com/emails"); private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly SocializeMetrics _metrics;
private readonly EmailerOptions _options; private readonly EmailerOptions _options;
public ResendEmailSender( public ResendEmailSender(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<EmailerOptions> options) IOptions<EmailerOptions> options,
SocializeMetrics metrics)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_metrics = metrics;
_options = options.Value; _options = options.Value;
string apiKey = NormalizeApiKey(_options.ApiKey);
string fromEmail = _options.FromEmail?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("Emailer:ApiKey is required when using Resend email delivery.");
}
if (string.IsNullOrWhiteSpace(fromEmail))
{
throw new InvalidOperationException("Emailer:FromEmail is required when using Resend email delivery.");
}
_options.ApiKey = apiKey;
_options.FromEmail = fromEmail;
_httpClient.DefaultRequestHeaders.Authorization = _httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _options.ApiKey); new AuthenticationHeaderValue("Bearer", apiKey);
_httpClient.DefaultRequestHeaders.Accept.Add( _httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json")); new MediaTypeWithQualityHeaderValue("application/json"));
} }
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage) public async Task SendEmailAsync(string email, string subject, string message)
{ {
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage }; var payload = new { from = _options.FromEmail, to = email, subject, html = message };
string json = JsonSerializer.Serialize(payload); string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json"); using StringContent content = new(json, Encoding.UTF8, "application/json");
try
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode)
{ {
string body = await response.Content.ReadAsStringAsync(); using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
throw new InvalidOperationException(
$"Resend email failed: {response.StatusCode} - {body}"); if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Resend email failed: {response.StatusCode} - {body}");
}
_metrics.RecordEmailDelivery("resend", true);
}
catch (HttpRequestException)
{
_metrics.RecordEmailDelivery("resend", false);
throw;
}
catch (TaskCanceledException)
{
_metrics.RecordEmailDelivery("resend", false);
throw;
}
catch (InvalidOperationException)
{
_metrics.RecordEmailDelivery("resend", false);
throw;
} }
} }
private static string NormalizeApiKey(string? apiKey)
{
string normalized = apiKey?.Trim().Trim('"', '\'') ?? string.Empty;
const string bearerPrefix = "Bearer ";
if (normalized.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[bearerPrefix.Length..].Trim();
}
return normalized;
}
} }

View File

@@ -9,7 +9,7 @@ using Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
namespace Socialize.Api.Infrastructure; namespace Socialize.Api.Infrastructure;
public static class DependencyInjection internal static class InfrastructureRegistration
{ {
public static WebApplicationBuilder AddInfrastructureModule( public static WebApplicationBuilder AddInfrastructureModule(
this WebApplicationBuilder builder) this WebApplicationBuilder builder)
@@ -26,8 +26,14 @@ public static class DependencyInjection
builder.Services.Configure<EmailerOptions>( builder.Services.Configure<EmailerOptions>(
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection)); builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
builder.Services.AddTransient<IEmailSender, ResendEmailSender>(); if (builder.Environment.IsDevelopment())
//builder.Services.AddTransient<IEmailSender, EmailSender>(); {
builder.Services.AddTransient<IEmailSender, LoggerEmailSender>();
}
else
{
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
}
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Socialize.Api.Infrastructure.Emailer.Configuration;
namespace Socialize.Api.Infrastructure.Observability;
internal sealed class EmailerConfigurationHealthCheck(
IWebHostEnvironment environment,
IOptions<EmailerOptions> options)
: IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
if (environment.IsDevelopment())
{
return Task.FromResult(HealthCheckResult.Healthy("Development email sender logs email instead of delivering it."));
}
EmailerOptions value = options.Value;
if (string.IsNullOrWhiteSpace(value.ApiKey) || string.IsNullOrWhiteSpace(value.FromEmail))
{
return Task.FromResult(HealthCheckResult.Unhealthy("Emailer API key or from address is missing."));
}
return Task.FromResult(HealthCheckResult.Healthy("Emailer configuration is present."));
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.BlobStorage.Services;
namespace Socialize.Api.Infrastructure.Observability;
internal sealed class LocalBlobStorageHealthCheck(
LocalBlobStorage blobStorage,
IOptions<LocalBlobStorageOptions> options)
: IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
string rootPath = blobStorage.GetRootPath();
if (string.IsNullOrWhiteSpace(options.Value.RequestPath))
{
return HealthCheckResult.Unhealthy("Local blob storage request path is not configured.");
}
try
{
Directory.CreateDirectory(rootPath);
string probePath = Path.Combine(rootPath, ".healthcheck");
await File.WriteAllTextAsync(
probePath,
DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
cancellationToken);
File.Delete(probePath);
return HealthCheckResult.Healthy("Local blob storage is writable.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return HealthCheckResult.Unhealthy("Local blob storage is not writable.", ex);
}
}
}

View File

@@ -0,0 +1,162 @@
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Npgsql;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Socialize.Api.Infrastructure.Observability;
internal static class ObservabilityRegistration
{
private const string DefaultServiceName = "socialize-api";
public static WebApplicationBuilder AddObservability(this WebApplicationBuilder builder)
{
string serviceName = GetConfigurationValue(builder.Configuration, "OTEL_SERVICE_NAME", DefaultServiceName);
string serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
builder.Logging.Configure(options =>
{
options.ActivityTrackingOptions =
ActivityTrackingOptions.TraceId |
ActivityTrackingOptions.SpanId |
ActivityTrackingOptions.ParentId;
});
builder.Logging.AddJsonConsole(options =>
{
options.IncludeScopes = true;
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
options.UseUtcTimestamp = true;
options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
});
bool otlpEnabled = HasOtlpEndpoint(builder.Configuration);
if (otlpEnabled)
{
builder.Logging.AddOpenTelemetry(options =>
{
options.IncludeFormattedMessage = true;
options.IncludeScopes = true;
options.ParseStateValues = true;
options.SetResourceBuilder(BuildResource(serviceName, serviceVersion));
options.AddOtlpExporter();
});
}
builder.Services.AddSingleton<SocializeMetrics>();
builder.Services.AddHostedService<WorkflowHealthSamplerService>();
builder.Services
.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName,
serviceVersion: serviceVersion))
.WithTracing(tracing =>
{
tracing
.AddSource(SocializeMetrics.ActivitySourceName)
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddNpgsql();
if (otlpEnabled)
{
tracing.AddOtlpExporter();
}
})
.WithMetrics(metrics =>
{
metrics
.AddMeter(SocializeMetrics.MeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
if (otlpEnabled)
{
metrics.AddOtlpExporter();
}
});
return builder;
}
public static IApplicationBuilder UseObservabilityLoggingScope(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestLoggingScopeMiddleware>();
}
public static IEndpointRouteBuilder MapObservabilityHealthChecks(this IEndpointRouteBuilder endpoints)
{
endpoints.MapHealthChecks(
"/health",
new HealthCheckOptions { ResponseWriter = WriteHealthResponseAsync });
endpoints.MapHealthChecks(
"/health/live",
new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("live", StringComparer.Ordinal),
ResponseWriter = WriteHealthResponseAsync,
});
endpoints.MapHealthChecks(
"/health/ready",
new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready", StringComparer.Ordinal),
ResponseWriter = WriteHealthResponseAsync,
});
return endpoints;
}
private static ResourceBuilder BuildResource(string serviceName, string serviceVersion)
{
return ResourceBuilder.CreateDefault().AddService(
serviceName,
serviceVersion: serviceVersion);
}
private static bool HasOtlpEndpoint(ConfigurationManager configuration)
{
return !string.IsNullOrWhiteSpace(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]) ||
!string.IsNullOrWhiteSpace(configuration["Otlp:Endpoint"]);
}
private static string GetConfigurationValue(
ConfigurationManager configuration,
string key,
string fallback)
{
string? value = configuration[key];
return string.IsNullOrWhiteSpace(value) ? fallback : value;
}
private static async Task WriteHealthResponseAsync(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var response = new
{
status = report.Status.ToString(),
checks = report.Entries.Select(entry => new
{
name = entry.Key,
status = entry.Value.Status.ToString(),
description = entry.Value.Description,
duration = entry.Value.Duration.TotalMilliseconds,
}),
duration = report.TotalDuration.TotalMilliseconds,
};
await JsonSerializer.SerializeAsync(
context.Response.Body,
response,
cancellationToken: context.RequestAborted);
}
}

View File

@@ -0,0 +1,61 @@
using System.Diagnostics;
using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Observability;
internal sealed class RequestLoggingScopeMiddleware(
RequestDelegate next,
ILogger<RequestLoggingScopeMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
Dictionary<string, object?> scope = new()
{
["trace_id"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
["span_id"] = Activity.Current?.SpanId.ToString(),
["http.method"] = context.Request.Method,
["url.path"] = context.Request.Path.Value,
};
if (context.User.Identity?.IsAuthenticated == true)
{
scope["user.id"] = context.User.GetUserId();
scope["user.email"] = context.User.GetEmail();
}
AddGuidIfPresent(scope, "organization.id", context, "organizationId");
AddGuidIfPresent(scope, "workspace.id", context, "workspaceId");
AddGuidIfPresent(scope, "client.id", context, "clientId");
AddGuidIfPresent(scope, "campaign.id", context, "campaignId");
AddGuidIfPresent(scope, "content_item.id", context, "contentItemId");
using IDisposable? _ = logger.BeginScope(scope);
await next(context);
}
private static void AddGuidIfPresent(
Dictionary<string, object?> scope,
string scopeKey,
HttpContext context,
string requestKey)
{
string? value = GetRouteOrQueryValue(context, requestKey);
if (Guid.TryParse(value, out Guid id))
{
scope[scopeKey] = id;
}
}
private static string? GetRouteOrQueryValue(HttpContext context, string key)
{
object? routeValue = context.Request.RouteValues[key];
if (routeValue is not null)
{
return Convert.ToString(routeValue, System.Globalization.CultureInfo.InvariantCulture);
}
return context.Request.Query.TryGetValue(key, out Microsoft.Extensions.Primitives.StringValues queryValue)
? queryValue.ToString()
: null;
}
}

View File

@@ -0,0 +1,258 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace Socialize.Api.Infrastructure.Observability;
internal sealed class SocializeMetrics : IDisposable
{
public const string MeterName = "Socialize.Api";
public const string ActivitySourceName = "Socialize.Api";
private readonly Counter<long> _approvalDecisionCounter;
private readonly Counter<long> _backgroundJobRunCounter;
private readonly Counter<long> _blobStorageOperationCounter;
private readonly Counter<long> _commentCreatedCounter;
private readonly Counter<long> _contentItemCreatedCounter;
private readonly Counter<long> _emailDeliveryCounter;
private readonly Counter<long> _feedbackSubmittedCounter;
private readonly Counter<long> _loginAttemptCounter;
private readonly Counter<long> _organizationCreatedCounter;
private readonly Counter<long> _workspaceCreatedCounter;
private readonly Counter<long> _workspaceInviteCreatedCounter;
private readonly object _workflowHealthLock = new();
private WorkflowHealthSnapshot _workflowHealthSnapshot = WorkflowHealthSnapshot.Empty;
public SocializeMetrics()
{
Meter = new Meter(MeterName);
ActivitySource = new ActivitySource(ActivitySourceName);
_loginAttemptCounter = Meter.CreateCounter<long>(
"socialize.login.attempts",
description: "Login attempts partitioned by outcome.");
_organizationCreatedCounter = Meter.CreateCounter<long>(
"socialize.organizations.created",
description: "Organizations created.");
_workspaceCreatedCounter = Meter.CreateCounter<long>(
"socialize.workspaces.created",
description: "Workspaces created.");
_contentItemCreatedCounter = Meter.CreateCounter<long>(
"socialize.content_items.created",
description: "Content items created.");
_commentCreatedCounter = Meter.CreateCounter<long>(
"socialize.comments.created",
description: "Comments created.");
_approvalDecisionCounter = Meter.CreateCounter<long>(
"socialize.approval_decisions.submitted",
description: "Approval decisions submitted.");
_feedbackSubmittedCounter = Meter.CreateCounter<long>(
"socialize.feedback.submitted",
description: "Feedback reports submitted.");
_workspaceInviteCreatedCounter = Meter.CreateCounter<long>(
"socialize.workspace_invites.created",
description: "Workspace invites created.");
_emailDeliveryCounter = Meter.CreateCounter<long>(
"socialize.email.delivery",
description: "Email delivery attempts partitioned by outcome and provider.");
_blobStorageOperationCounter = Meter.CreateCounter<long>(
"socialize.blob_storage.operations",
description: "Blob storage operations partitioned by operation and outcome.");
_backgroundJobRunCounter = Meter.CreateCounter<long>(
"socialize.background_job.runs",
description: "Background job runs partitioned by job and outcome.");
Meter.CreateObservableGauge(
"socialize.workflow.content_items",
ObserveContentItemCounts,
description: "Current content item counts by status.");
Meter.CreateObservableGauge(
"socialize.workflow.feedback_reports",
ObserveFeedbackReportCounts,
description: "Current feedback report counts by status.");
Meter.CreateObservableGauge(
"socialize.workflow.pending_invites",
ObservePendingInviteCount,
description: "Current pending workspace invite count.");
Meter.CreateObservableGauge(
"socialize.workflow.stale_in_approval",
ObserveStaleApprovalCount,
description: "Current count of content items in approval longer than the configured stale threshold.");
Meter.CreateObservableGauge(
"socialize.workflow.active_workspaces",
ObserveActiveWorkspaceCounts,
description: "Current active workspace counts by observation window.");
}
public Meter Meter { get; }
public ActivitySource ActivitySource { get; }
public void RecordLoginAttempt(bool succeeded, string reason)
{
_loginAttemptCounter.Add(
1,
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"),
new KeyValuePair<string, object?>("reason", reason));
}
public void RecordOrganizationCreated(Guid organizationId)
{
_organizationCreatedCounter.Add(
1,
new KeyValuePair<string, object?>("organization.id", organizationId));
}
public void RecordWorkspaceCreated(Guid organizationId, Guid workspaceId)
{
_workspaceCreatedCounter.Add(
1,
new KeyValuePair<string, object?>("organization.id", organizationId),
new KeyValuePair<string, object?>("workspace.id", workspaceId));
}
public void RecordContentItemCreated(Guid workspaceId)
{
_contentItemCreatedCounter.Add(
1,
new KeyValuePair<string, object?>("workspace.id", workspaceId));
}
public void RecordCommentCreated(Guid workspaceId, bool hasAttachment)
{
_commentCreatedCounter.Add(
1,
new KeyValuePair<string, object?>("workspace.id", workspaceId),
new KeyValuePair<string, object?>("has_attachment", hasAttachment));
}
public void RecordApprovalDecisionSubmitted(Guid workspaceId, string decision)
{
_approvalDecisionCounter.Add(
1,
new KeyValuePair<string, object?>("workspace.id", workspaceId),
new KeyValuePair<string, object?>("decision", decision));
}
public void RecordFeedbackSubmitted(string type, Guid? workspaceId)
{
_feedbackSubmittedCounter.Add(
1,
new KeyValuePair<string, object?>("feedback.type", type),
new KeyValuePair<string, object?>("workspace.id", workspaceId?.ToString() ?? "none"));
}
public void RecordWorkspaceInviteCreated(Guid workspaceId, string role)
{
_workspaceInviteCreatedCounter.Add(
1,
new KeyValuePair<string, object?>("workspace.id", workspaceId),
new KeyValuePair<string, object?>("role", role));
}
public void RecordEmailDelivery(string provider, bool succeeded)
{
_emailDeliveryCounter.Add(
1,
new KeyValuePair<string, object?>("provider", provider),
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
}
public void RecordBlobStorageOperation(string operation, bool succeeded)
{
_blobStorageOperationCounter.Add(
1,
new KeyValuePair<string, object?>("operation", operation),
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
}
public void RecordBackgroundJobRun(string job, bool succeeded)
{
_backgroundJobRunCounter.Add(
1,
new KeyValuePair<string, object?>("job", job),
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
}
public void UpdateWorkflowHealth(WorkflowHealthSnapshot snapshot)
{
lock (_workflowHealthLock)
{
_workflowHealthSnapshot = snapshot;
}
}
public void Dispose()
{
Meter.Dispose();
ActivitySource.Dispose();
}
private Measurement<int>[] ObserveContentItemCounts()
{
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
return snapshot.ContentItemsByStatus
.Select(pair => new Measurement<int>(
pair.Value,
new KeyValuePair<string, object?>("status", pair.Key)))
.ToArray();
}
private Measurement<int>[] ObserveFeedbackReportCounts()
{
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
return snapshot.FeedbackReportsByStatus
.Select(pair => new Measurement<int>(
pair.Value,
new KeyValuePair<string, object?>("status", pair.Key)))
.ToArray();
}
private Measurement<int> ObservePendingInviteCount()
{
return new Measurement<int>(GetWorkflowHealthSnapshot().PendingInviteCount);
}
private Measurement<int> ObserveStaleApprovalCount()
{
return new Measurement<int>(GetWorkflowHealthSnapshot().StaleInApprovalCount);
}
private Measurement<int>[] ObserveActiveWorkspaceCounts()
{
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
return
[
new Measurement<int>(
snapshot.ActiveWorkspaces24Hours,
new KeyValuePair<string, object?>("window", "24h")),
new Measurement<int>(
snapshot.ActiveWorkspaces7Days,
new KeyValuePair<string, object?>("window", "7d")),
];
}
private WorkflowHealthSnapshot GetWorkflowHealthSnapshot()
{
lock (_workflowHealthLock)
{
return _workflowHealthSnapshot;
}
}
}
internal sealed record WorkflowHealthSnapshot(
IReadOnlyDictionary<string, int> ContentItemsByStatus,
IReadOnlyDictionary<string, int> FeedbackReportsByStatus,
int PendingInviteCount,
int StaleInApprovalCount,
int ActiveWorkspaces24Hours,
int ActiveWorkspaces7Days)
{
public static WorkflowHealthSnapshot Empty { get; } = new(
new Dictionary<string, int>(StringComparer.Ordinal),
new Dictionary<string, int>(StringComparer.Ordinal),
0,
0,
0,
0);
}

View File

@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Infrastructure.Observability;
internal sealed class WorkflowHealthSamplerService(
IServiceScopeFactory scopeFactory,
SocializeMetrics metrics,
ILogger<WorkflowHealthSamplerService> logger)
: BackgroundService
{
private static readonly TimeSpan SampleInterval = TimeSpan.FromMinutes(5);
private static readonly TimeSpan StaleApprovalThreshold = TimeSpan.FromDays(3);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SampleAsync(stoppingToken);
using PeriodicTimer timer = new(SampleInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await timer.WaitForNextTickAsync(stoppingToken);
await SampleAsync(stoppingToken);
}
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{
logger.LogDebug(ex, "Workflow health sampler stopped.");
}
}
}
private async Task SampleAsync(CancellationToken stoppingToken)
{
try
{
using IServiceScope scope = scopeFactory.CreateScope();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset staleApprovalCutoff = now.Subtract(StaleApprovalThreshold);
DateTimeOffset active24HourCutoff = now.AddHours(-24);
DateTimeOffset active7DayCutoff = now.AddDays(-7);
Dictionary<string, int> contentItemsByStatus = await dbContext.ContentItems
.GroupBy(item => item.Status)
.Select(group => new { Status = group.Key, Count = group.Count() })
.ToDictionaryAsync(group => group.Status, group => group.Count, StringComparer.Ordinal, stoppingToken);
Dictionary<string, int> feedbackReportsByStatus = await dbContext.FeedbackReports
.GroupBy(report => report.Status)
.Select(group => new { Status = group.Key, Count = group.Count() })
.ToDictionaryAsync(
group => group.Status == FeedbackStatus.WontDo ? "WontDo" : group.Status.ToString(),
group => group.Count,
StringComparer.Ordinal,
stoppingToken);
int pendingInviteCount = await dbContext.WorkspaceInvites
.CountAsync(invite => invite.Status == WorkspaceInviteStatuses.Pending, stoppingToken);
int staleInApprovalCount = await dbContext.ContentItems
.CountAsync(
item => item.Status == "In approval" && item.CreatedAt <= staleApprovalCutoff,
stoppingToken);
int activeWorkspaces24Hours = await dbContext.ContentItemActivityEntries
.Where(entry => entry.CreatedAt >= active24HourCutoff)
.Select(entry => entry.WorkspaceId)
.Distinct()
.CountAsync(stoppingToken);
int activeWorkspaces7Days = await dbContext.ContentItemActivityEntries
.Where(entry => entry.CreatedAt >= active7DayCutoff)
.Select(entry => entry.WorkspaceId)
.Distinct()
.CountAsync(stoppingToken);
metrics.UpdateWorkflowHealth(new WorkflowHealthSnapshot(
contentItemsByStatus,
feedbackReportsByStatus,
pendingInviteCount,
staleInApprovalCount,
activeWorkspaces24Hours,
activeWorkspaces7Days));
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), true);
}
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{
logger.LogDebug(ex, "Workflow health sampler stopped.");
}
#pragma warning disable CA1031
catch (Exception ex)
{
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), false);
logger.LogError(ex, "Workflow health sampling failed.");
}
#pragma warning restore CA1031
}
}

View File

@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
namespace Socialize.Api.Infrastructure.Payments.Stripe.Configuration; namespace Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
public class StripeOptions internal class StripeOptions
{ {
public const string ConfigurationSection = "Stripe"; public const string ConfigurationSection = "Stripe";

View File

@@ -1,56 +1,175 @@
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 internal sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{ {
public bool IsManager(ClaimsPrincipal user) public static bool IsManager(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
} }
public bool IsProvider(ClaimsPrincipal user) public static bool IsProvider(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Provider); return user.IsInRole(KnownRoles.Provider);
} }
public bool IsClient(ClaimsPrincipal user) public static bool IsClient(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Client); return user.IsInRole(KnownRoles.Client);
} }
public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId) public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
{ {
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId); return user.GetWorkspaceScopeIds().Contains(workspaceId);
} }
public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
{ {
return IsManager(user) && CanAccessWorkspace(user, workspaceId); return IsManager(user) && CanAccessWorkspace(user, workspaceId);
} }
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId) public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{ {
return IsManager(user) return CanAccessWorkspace(user, workspaceId) &&
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); (IsManager(user) || user.GetClientScopeIds().Contains(clientId));
} }
public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) return CanAccessClient(user, workspaceId, clientId) &&
|| (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId)); (IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
} }
public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)); return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId);
} }
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| 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 user.GetWorkspaceScopeIds().Contains(workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct);
}
public async Task<bool> CanManageWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return CanManageWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct);
}
public async Task<bool> CanCreateWorkspaceAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
return await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
ct);
}
public async Task<bool> CanAccessClientAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
CancellationToken ct)
{
if (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 (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 CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| 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 CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| 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

@@ -1,8 +1,9 @@
using System.Security.Claims; using System.Globalization;
using System.Security.Claims;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public static class ClaimsPrincipalExtensions internal static class ClaimsPrincipalExtensions
{ {
public static IReadOnlyCollection<Guid> GetScopeIds(this ClaimsPrincipal claims, string key) public static IReadOnlyCollection<Guid> GetScopeIds(this ClaimsPrincipal claims, string key)
{ {
@@ -23,9 +24,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)
@@ -81,11 +82,11 @@ public static class ClaimsPrincipalExtensions
if (claim is null) if (claim is null)
{ {
throw new MissingClaimException(key); throw MissingClaimException.ForClaim(key);
} }
return typeof(TValue) == typeof(Guid) return typeof(TValue) == typeof(Guid)
? Guid.Parse(claim.Value) ? Guid.Parse(claim.Value)
: Convert.ChangeType(claim.Value, typeof(TValue)); : Convert.ChangeType(claim.Value, typeof(TValue), CultureInfo.InvariantCulture);
} }
} }

View File

@@ -5,7 +5,7 @@ using Microsoft.IdentityModel.Tokens;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public static class JwtTokenHelper internal static class JwtTokenHelper
{ {
public static string GenerateJwtToken( public static string GenerateJwtToken(
TimeSpan expiresIn, TimeSpan expiresIn,

View File

@@ -1,11 +1,11 @@
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public static class KnownClaims internal static class KnownClaims
{ {
public const string Alias = "alias"; public const string Alias = "alias";
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,5 +1,23 @@
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public class MissingClaimException( public class MissingClaimException : Exception
string claimName) {
: Exception($"Claim '{claimName}' is missing."); public MissingClaimException()
{
}
public MissingClaimException(string message)
: base(message)
{
}
public MissingClaimException(string message, Exception innerException)
: base(message, innerException)
{
}
internal static MissingClaimException ForClaim(string claimName)
{
return new MissingClaimException($"Claim '{claimName}' is missing.");
}
}

View File

@@ -4,15 +4,13 @@ using System.Text;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
// If we need to add special characters we can alternate between 2 pools. // If we need to add special characters we can alternate between 2 pools.
public static class PasswordGenerator internal static class PasswordGenerator
{ {
private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz";
private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private const string Numbers = "0123456789"; private const string Numbers = "0123456789";
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?"; private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
private static readonly Random Random = new();
public static string Next( public static string Next(
int length = 15, int length = 15,
bool requireNumber = true, bool requireNumber = true,
@@ -23,7 +21,7 @@ public static class PasswordGenerator
// Create pools based on the requirements // Create pools based on the requirements
StringBuilder characterPool = new(); StringBuilder characterPool = new();
if (requireNumber) if (requireLowercase)
{ {
characterPool.Append(LowerLetters); characterPool.Append(LowerLetters);
} }
@@ -51,22 +49,22 @@ public static class PasswordGenerator
if (requireLowercase) if (requireLowercase)
{ {
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)]; password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)];
} }
if (requireCapital) if (requireCapital)
{ {
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)]; password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)];
} }
if (requireNumber) if (requireNumber)
{ {
password[index++] = Numbers[Random.Next(Numbers.Length)]; password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)];
} }
if (requireSpecialCharacter) if (requireSpecialCharacter)
{ {
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)]; password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)];
} }
// Fill the rest with the password // Fill the rest with the password
@@ -85,7 +83,7 @@ public static class PasswordGenerator
{ {
for (int i = array.Length - 1; i > 0; i--) for (int i = array.Length - 1; i > 0; i--)
{ {
int j = Random.Next(i + 1); int j = RandomNumberGenerator.GetInt32(i + 1);
(array[i], array[j]) = (array[j], array[i]); // Swap elements (array[i], array[j]) = (array[j], array[i]); // Swap elements
} }
} }

View File

@@ -2,7 +2,7 @@ using System.Security.Cryptography;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public static class RefreshTokenGenerator internal static class RefreshTokenGenerator
{ {
public static string Next() public static string Next()
{ {

View File

@@ -6,24 +6,33 @@ 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;
namespace Socialize.Api.Infrastructure.Development; namespace Socialize.Api.Infrastructure.TestData;
public static class DevelopmentSeedExtensions #pragma warning disable S1075 // Test data intentionally uses representative external URLs.
internal static class TestDataSeedExtensions
{ {
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");
@@ -31,23 +40,11 @@ public static class DevelopmentSeedExtensions
private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777"); private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777");
private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888"); private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888");
public static async Task<IApplicationBuilder> UseDevelopmentSeedAsync( public static async Task<IServiceProvider> SeedTestDataAsync(
this IApplicationBuilder app, this IServiceProvider services,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
IHostEnvironment environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>(); using IServiceScope scope = services.CreateScope();
if (!environment.IsDevelopment())
{
return app;
}
using IServiceScope scope = app.ApplicationServices.CreateScope();
IOptions<DevelopmentSeedOptions> options = scope.ServiceProvider.GetRequiredService<IOptions<DevelopmentSeedOptions>>();
if (!options.Value.Enabled)
{
return app;
}
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>(); UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -56,7 +53,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
username: "manager", username: "manager",
email: "manager@socialize.local", email: "manager@socialize.local",
password: "manager", password: "Manager1!",
alias: "Northstar Manager", alias: "Northstar Manager",
firstname: "Morgan", firstname: "Morgan",
lastname: "Reid", lastname: "Reid",
@@ -72,7 +69,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
username: "client", username: "client",
email: "client@socialize.local", email: "client@socialize.local",
password: "client", password: "Client1!",
alias: "Sofia Martin", alias: "Sofia Martin",
firstname: "Sofia", firstname: "Sofia",
lastname: "Martin", lastname: "Martin",
@@ -89,7 +86,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
username: "provider", username: "provider",
email: "provider@socialize.local", email: "provider@socialize.local",
password: "provider", password: "Provider1!",
alias: "Alex Studio", alias: "Alex Studio",
firstname: "Alex", firstname: "Alex",
lastname: "Studio", lastname: "Studio",
@@ -99,7 +96,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(
@@ -107,7 +104,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
username: "dev", username: "dev",
email: "dev@socialize.local", email: "dev@socialize.local",
password: "dev", password: "Developer1!",
alias: "Socialize Dev", alias: "Socialize Dev",
firstname: "Jo", firstname: "Jo",
lastname: "Bumble", lastname: "Bumble",
@@ -117,6 +114,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,
@@ -124,7 +127,7 @@ public static class DevelopmentSeedExtensions
dbContext, dbContext,
cancellationToken); cancellationToken);
return app; return services;
} }
private static async Task<User> EnsureUserAsync( private static async Task<User> EnsureUserAsync(
@@ -161,7 +164,7 @@ public static class DevelopmentSeedExtensions
if (!createResult.Succeeded) if (!createResult.Succeeded)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}"); $"Failed to seed test user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
} }
} }
@@ -181,7 +184,7 @@ public static class DevelopmentSeedExtensions
if (!passwordResetResult.Succeeded) if (!passwordResetResult.Succeeded)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}"); $"Failed to set test password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
} }
} }
@@ -200,7 +203,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)
@@ -208,13 +211,7 @@ public static class DevelopmentSeedExtensions
await userManager.RemoveClaimAsync(user, claim); await userManager.RemoveClaimAsync(user, claim);
} }
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) string persona = GetPersona(roles);
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)])) foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{ {
@@ -224,6 +221,96 @@ public static class DevelopmentSeedExtensions
return user; return user;
} }
private static string GetPersona(IReadOnlyCollection<string> roles)
{
if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal))
{
return KnownRoles.Manager;
}
if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal))
{
return KnownRoles.Client;
}
if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal))
{
return KnownRoles.Provider;
}
return KnownRoles.WorkspaceMember;
}
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.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
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 +318,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 +352,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 +370,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 +383,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 +428,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 +491,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 +569,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 +634,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 +647,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 +706,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 +733,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

@@ -2,7 +2,7 @@ using System.Text.RegularExpressions;
namespace Socialize.Api.Infrastructure.YouTube; namespace Socialize.Api.Infrastructure.YouTube;
public static class YouTubeUrlHelper internal static class YouTubeUrlHelper
{ {
private static readonly Regex VideoIdRegex = new( private static readonly Regex VideoIdRegex = new(
@"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})", @"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})",

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("20260507143849_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

@@ -0,0 +1,405 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddMissingDomainForeignKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_CampaignId",
table: "FeedbackReports",
column: "CampaignId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ClientId",
table: "FeedbackReports",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ContentItemId",
table: "FeedbackReports",
column: "ContentItemId");
migrationBuilder.AddForeignKey(
name: "FK_ApprovalDecisions_ApprovalRequests_ApprovalRequestId",
table: "ApprovalDecisions",
column: "ApprovalRequestId",
principalTable: "ApprovalRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ApprovalRequests_ApprovalWorkflowInstances_WorkflowInstance~",
table: "ApprovalRequests",
column: "WorkflowInstanceId",
principalTable: "ApprovalWorkflowInstances",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ApprovalRequests_ContentItems_ContentItemId",
table: "ApprovalRequests",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ApprovalRequests_Workspaces_WorkspaceId",
table: "ApprovalRequests",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ApprovalWorkflowInstances_ContentItems_ContentItemId",
table: "ApprovalWorkflowInstances",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ApprovalWorkflowInstances_Workspaces_WorkspaceId",
table: "ApprovalWorkflowInstances",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_AssetRevisions_Assets_AssetId",
table: "AssetRevisions",
column: "AssetId",
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Assets_ContentItems_ContentItemId",
table: "Assets",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Assets_Workspaces_WorkspaceId",
table: "Assets",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Campaigns_Clients_ClientId",
table: "Campaigns",
column: "ClientId",
principalTable: "Clients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Campaigns_Workspaces_WorkspaceId",
table: "Campaigns",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Channels_Workspaces_WorkspaceId",
table: "Channels",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Clients_Workspaces_WorkspaceId",
table: "Clients",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Comments_Comments_ParentCommentId",
table: "Comments",
column: "ParentCommentId",
principalTable: "Comments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Comments_ContentItems_ContentItemId",
table: "Comments",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Comments_Workspaces_WorkspaceId",
table: "Comments",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ContentItemActivityEntries_ContentItems_ContentItemId",
table: "ContentItemActivityEntries",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ContentItemActivityEntries_Workspaces_WorkspaceId",
table: "ContentItemActivityEntries",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ContentItemRevisions_ContentItems_ContentItemId",
table: "ContentItemRevisions",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ContentItems_Campaigns_CampaignId",
table: "ContentItems",
column: "CampaignId",
principalTable: "Campaigns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ContentItems_Clients_ClientId",
table: "ContentItems",
column: "ClientId",
principalTable: "Clients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ContentItems_Workspaces_WorkspaceId",
table: "ContentItems",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_FeedbackReports_Campaigns_CampaignId",
table: "FeedbackReports",
column: "CampaignId",
principalTable: "Campaigns",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_FeedbackReports_Clients_ClientId",
table: "FeedbackReports",
column: "ClientId",
principalTable: "Clients",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_FeedbackReports_ContentItems_ContentItemId",
table: "FeedbackReports",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_FeedbackReports_Workspaces_WorkspaceId",
table: "FeedbackReports",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_NotificationEvents_ContentItems_ContentItemId",
table: "NotificationEvents",
column: "ContentItemId",
principalTable: "ContentItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_NotificationEvents_Workspaces_WorkspaceId",
table: "NotificationEvents",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_WorkspaceApprovalStepConfigurations_Workspaces_WorkspaceId",
table: "WorkspaceApprovalStepConfigurations",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_WorkspaceInvites_Workspaces_WorkspaceId",
table: "WorkspaceInvites",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ApprovalDecisions_ApprovalRequests_ApprovalRequestId",
table: "ApprovalDecisions");
migrationBuilder.DropForeignKey(
name: "FK_ApprovalRequests_ApprovalWorkflowInstances_WorkflowInstance~",
table: "ApprovalRequests");
migrationBuilder.DropForeignKey(
name: "FK_ApprovalRequests_ContentItems_ContentItemId",
table: "ApprovalRequests");
migrationBuilder.DropForeignKey(
name: "FK_ApprovalRequests_Workspaces_WorkspaceId",
table: "ApprovalRequests");
migrationBuilder.DropForeignKey(
name: "FK_ApprovalWorkflowInstances_ContentItems_ContentItemId",
table: "ApprovalWorkflowInstances");
migrationBuilder.DropForeignKey(
name: "FK_ApprovalWorkflowInstances_Workspaces_WorkspaceId",
table: "ApprovalWorkflowInstances");
migrationBuilder.DropForeignKey(
name: "FK_AssetRevisions_Assets_AssetId",
table: "AssetRevisions");
migrationBuilder.DropForeignKey(
name: "FK_Assets_ContentItems_ContentItemId",
table: "Assets");
migrationBuilder.DropForeignKey(
name: "FK_Assets_Workspaces_WorkspaceId",
table: "Assets");
migrationBuilder.DropForeignKey(
name: "FK_Campaigns_Clients_ClientId",
table: "Campaigns");
migrationBuilder.DropForeignKey(
name: "FK_Campaigns_Workspaces_WorkspaceId",
table: "Campaigns");
migrationBuilder.DropForeignKey(
name: "FK_Channels_Workspaces_WorkspaceId",
table: "Channels");
migrationBuilder.DropForeignKey(
name: "FK_Clients_Workspaces_WorkspaceId",
table: "Clients");
migrationBuilder.DropForeignKey(
name: "FK_Comments_Comments_ParentCommentId",
table: "Comments");
migrationBuilder.DropForeignKey(
name: "FK_Comments_ContentItems_ContentItemId",
table: "Comments");
migrationBuilder.DropForeignKey(
name: "FK_Comments_Workspaces_WorkspaceId",
table: "Comments");
migrationBuilder.DropForeignKey(
name: "FK_ContentItemActivityEntries_ContentItems_ContentItemId",
table: "ContentItemActivityEntries");
migrationBuilder.DropForeignKey(
name: "FK_ContentItemActivityEntries_Workspaces_WorkspaceId",
table: "ContentItemActivityEntries");
migrationBuilder.DropForeignKey(
name: "FK_ContentItemRevisions_ContentItems_ContentItemId",
table: "ContentItemRevisions");
migrationBuilder.DropForeignKey(
name: "FK_ContentItems_Campaigns_CampaignId",
table: "ContentItems");
migrationBuilder.DropForeignKey(
name: "FK_ContentItems_Clients_ClientId",
table: "ContentItems");
migrationBuilder.DropForeignKey(
name: "FK_ContentItems_Workspaces_WorkspaceId",
table: "ContentItems");
migrationBuilder.DropForeignKey(
name: "FK_FeedbackReports_Campaigns_CampaignId",
table: "FeedbackReports");
migrationBuilder.DropForeignKey(
name: "FK_FeedbackReports_Clients_ClientId",
table: "FeedbackReports");
migrationBuilder.DropForeignKey(
name: "FK_FeedbackReports_ContentItems_ContentItemId",
table: "FeedbackReports");
migrationBuilder.DropForeignKey(
name: "FK_FeedbackReports_Workspaces_WorkspaceId",
table: "FeedbackReports");
migrationBuilder.DropForeignKey(
name: "FK_NotificationEvents_ContentItems_ContentItemId",
table: "NotificationEvents");
migrationBuilder.DropForeignKey(
name: "FK_NotificationEvents_Workspaces_WorkspaceId",
table: "NotificationEvents");
migrationBuilder.DropForeignKey(
name: "FK_WorkspaceApprovalStepConfigurations_Workspaces_WorkspaceId",
table: "WorkspaceApprovalStepConfigurations");
migrationBuilder.DropForeignKey(
name: "FK_WorkspaceInvites_Workspaces_WorkspaceId",
table: "WorkspaceInvites");
migrationBuilder.DropIndex(
name: "IX_FeedbackReports_CampaignId",
table: "FeedbackReports");
migrationBuilder.DropIndex(
name: "IX_FeedbackReports_ClientId",
table: "FeedbackReports");
migrationBuilder.DropIndex(
name: "IX_FeedbackReports_ContentItemId",
table: "FeedbackReports");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddOrganizationMembershipTiers : Migration
{
private static readonly string[] MembershipTierSeedColumns =
[
"Id",
"ActiveContentLimit",
"Description",
"ExternalReviewerLimit",
"IsCustom",
"Key",
"MemberLimit",
"MonthlyPriceCents",
"Name",
"SortOrder",
"WorkspaceLimit"
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<Guid>(
name: "MembershipTierId",
table: "Organizations",
type: "uuid",
nullable: false,
defaultValue: new Guid("20000000-0000-0000-0000-000000000001"));
migrationBuilder.CreateTable(
name: "OrganizationMembershipTiers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
MonthlyPriceCents = table.Column<int>(type: "integer", nullable: true),
WorkspaceLimit = table.Column<int>(type: "integer", nullable: true),
ActiveContentLimit = table.Column<int>(type: "integer", nullable: true),
MemberLimit = table.Column<int>(type: "integer", nullable: true),
ExternalReviewerLimit = table.Column<int>(type: "integer", nullable: true),
IsCustom = table.Column<bool>(type: "boolean", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMembershipTiers", x => x.Id);
});
migrationBuilder.InsertData(
table: "OrganizationMembershipTiers",
columns: MembershipTierSeedColumns,
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000001"), 3, "For trying Socialize on one real approval workflow.", 1, false, "free", 2, 0, "Free", 10, 1 },
{ new Guid("20000000-0000-0000-0000-000000000002"), 25, "For solo operators managing recurring client reviews.", 10, false, "freelance", 5, 1900, "Freelance", 20, 3 },
{ new Guid("20000000-0000-0000-0000-000000000003"), 250, "For agencies that need repeatable client approval operations.", null, false, "agency", 25, 7900, "Agency", 30, 15 },
{ new Guid("20000000-0000-0000-0000-000000000004"), null, "For larger organizations with governance and access needs.", null, true, "enterprise", null, null, "Enterprise", 40, null }
});
migrationBuilder.CreateIndex(
name: "IX_Organizations_MembershipTierId",
table: "Organizations",
column: "MembershipTierId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMembershipTiers_Key",
table: "OrganizationMembershipTiers",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OrganizationMembershipTiers_SortOrder",
table: "OrganizationMembershipTiers",
column: "SortOrder");
migrationBuilder.AddForeignKey(
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
table: "Organizations",
column: "MembershipTierId",
principalTable: "OrganizationMembershipTiers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropForeignKey(
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
table: "Organizations");
migrationBuilder.DropTable(
name: "OrganizationMembershipTiers");
migrationBuilder.DropIndex(
name: "IX_Organizations_MembershipTierId",
table: "Organizations");
migrationBuilder.DropColumn(
name: "MembershipTierId",
table: "Organizations");
}
}
}

View File

@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class LocalizeOrganizationMembershipTiers : Migration
{
private static readonly string[] MembershipTierTranslationSeedColumns =
[
"Id",
"Culture",
"Description",
"MembershipTierId",
"Name"
];
private static readonly string[] MembershipTierColumnsToRestore =
[
"Description",
"Name"
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "Description",
table: "OrganizationMembershipTiers");
migrationBuilder.DropColumn(
name: "Name",
table: "OrganizationMembershipTiers");
migrationBuilder.CreateTable(
name: "OrganizationMembershipTierTranslations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MembershipTierId = table.Column<Guid>(type: "uuid", nullable: false),
Culture = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMembershipTierTranslations", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMembershipTierTranslations_OrganizationMembersh~",
column: x => x.MembershipTierId,
principalTable: "OrganizationMembershipTiers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "OrganizationMembershipTierTranslations",
columns: MembershipTierTranslationSeedColumns,
values: new object[,]
{
{ new Guid("20000000-0000-0001-0000-000000000001"), "en", "For trying Socialize on one real approval workflow.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
{ new Guid("20000000-0000-0001-0000-000000000002"), "fr", "Pour essayer Socialize sur un vrai workflow d'approbation.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
{ new Guid("20000000-0000-0001-0000-000000000003"), "en", "For solo operators managing recurring client reviews.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
{ new Guid("20000000-0000-0001-0000-000000000004"), "fr", "Pour les independants qui gerent des revisions client recurrentes.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
{ new Guid("20000000-0000-0001-0000-000000000005"), "en", "For agencies that need repeatable client approval operations.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
{ new Guid("20000000-0000-0001-0000-000000000006"), "fr", "Pour les agences qui veulent des operations d'approbation client repetables.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
{ new Guid("20000000-0000-0001-0000-000000000007"), "en", "For larger organizations with governance and access needs.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" },
{ new Guid("20000000-0000-0001-0000-000000000008"), "fr", "Pour les grandes organisations avec des besoins de gouvernance et d'acces.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" }
});
migrationBuilder.CreateIndex(
name: "IX_OrganizationMembershipTierTranslations_MembershipTierId_Cul~",
table: "OrganizationMembershipTierTranslations",
columns: ["MembershipTierId", "Culture"],
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OrganizationMembershipTierTranslations");
migrationBuilder.AddColumn<string>(
name: "Description",
table: "OrganizationMembershipTiers",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Name",
table: "OrganizationMembershipTiers",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For trying Socialize on one real approval workflow.", "Free" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For solo operators managing recurring client reviews.", "Freelance" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000003"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For agencies that need repeatable client approval operations.", "Agency" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000004"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For larger organizations with governance and access needs.", "Enterprise" });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddReleaseCommunications : Migration
{
private static readonly string[] ReleaseUpdateReadReceiptUniqueIndexColumns =
[
"ReleaseUpdateId",
"UserId",
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastAuthenticatedAt",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "ReleaseUpdateEmailDigestReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdateCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdateEmailDigestReceipts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReleaseUpdates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
Summary = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: true),
Category = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Importance = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Audience = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
DeploymentLabel = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
BuildVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
CommitRange = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
PublishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ArchivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ManualEmailSentByUserId = table.Column<Guid>(type: "uuid", nullable: true),
ManualEmailSentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ManualEmailAudience = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ManualEmailRecipientCount = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdates", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReleaseCommits",
columns: table => new
{
Sha = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ShortSha = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Subject = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
AuthorName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AuthoredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CommittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
SourceBranch = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
DeploymentLabel = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CommunicationStatus = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ReleaseUpdateId = table.Column<Guid>(type: "uuid", nullable: true),
ImportedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseCommits", x => x.Sha);
table.ForeignKey(
name: "FK_ReleaseCommits_ReleaseUpdates_ReleaseUpdateId",
column: x => x.ReleaseUpdateId,
principalTable: "ReleaseUpdates",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "ReleaseUpdateReadReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReleaseUpdateId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ReadAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdateReadReceipts", x => x.Id);
table.ForeignKey(
name: "FK_ReleaseUpdateReadReceipts_ReleaseUpdates_ReleaseUpdateId",
column: x => x.ReleaseUpdateId,
principalTable: "ReleaseUpdates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_CommittedAt",
table: "ReleaseCommits",
column: "CommittedAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_CommunicationStatus",
table: "ReleaseCommits",
column: "CommunicationStatus");
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_ReleaseUpdateId",
table: "ReleaseCommits",
column: "ReleaseUpdateId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateEmailDigestReceipts_SentAt",
table: "ReleaseUpdateEmailDigestReceipts",
column: "SentAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateEmailDigestReceipts_UserId",
table: "ReleaseUpdateEmailDigestReceipts",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateReadReceipts_ReleaseUpdateId_UserId",
table: "ReleaseUpdateReadReceipts",
columns: ReleaseUpdateReadReceiptUniqueIndexColumns,
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateReadReceipts_UserId",
table: "ReleaseUpdateReadReceipts",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates",
column: "Audience");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_CreatedByUserId",
table: "ReleaseUpdates",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_PublishedAt",
table: "ReleaseUpdates",
column: "PublishedAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Status",
table: "ReleaseUpdates",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReleaseCommits");
migrationBuilder.DropTable(
name: "ReleaseUpdateEmailDigestReceipts");
migrationBuilder.DropTable(
name: "ReleaseUpdateReadReceipts");
migrationBuilder.DropTable(
name: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "LastAuthenticatedAt",
table: "AspNetUsers");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class SimplifyReleaseUpdates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Body",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "BuildVersion",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Category",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "CommitRange",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "DeploymentLabel",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Importance",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailAudience",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Audience",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Body",
table: "ReleaseUpdates",
type: "character varying(8000)",
maxLength: 8000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BuildVersion",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Category",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CommitRange",
table: "ReleaseUpdates",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeploymentLabel",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Importance",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ManualEmailAudience",
table: "ReleaseUpdates",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates",
column: "Audience");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddFrenchReleaseUpdateFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TitleFr",
table: "ReleaseUpdates",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.Sql("""
UPDATE "ReleaseUpdates"
SET "TitleFr" = "Title",
"SummaryFr" = "Summary"
WHERE "TitleFr" = '' AND "SummaryFr" = '';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SummaryFr",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "TitleFr",
table: "ReleaseUpdates");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class RemoveManualReleaseUpdateEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentAt",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<int>(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ManualEmailSentAt",
table: "ReleaseUpdates",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates",
type: "uuid",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class ExpandReleaseUpdateDescriptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddUserPreferredLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "PreferredLanguage",
table: "AspNetUsers",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "PreferredLanguage",
table: "AspNetUsers");
}
}
}

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Approvals.Data; namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalDecision internal class ApprovalDecision
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid ApprovalRequestId { get; set; } public Guid ApprovalRequestId { get; set; }

View File

@@ -1,15 +1,43 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Data; namespace Socialize.Api.Modules.Approvals.Data;
public static class ApprovalModelConfiguration internal 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'");
workflowInstance.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
workflowInstance.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
});
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,7 +48,20 @@ 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);
approvalRequest.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
approvalRequest.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
approvalRequest.HasOne<ApprovalWorkflowInstance>()
.WithMany()
.HasForeignKey(x => x.WorkflowInstanceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<ApprovalDecision>(approvalDecision => modelBuilder.Entity<ApprovalDecision>(approvalDecision =>
@@ -35,6 +76,29 @@ public static class ApprovalModelConfiguration
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalDecision.HasIndex(x => x.ApprovalRequestId); approvalDecision.HasIndex(x => x.ApprovalRequestId);
approvalDecision.HasOne<ApprovalRequest>()
.WithMany()
.HasForeignKey(x => x.ApprovalRequestId)
.OnDelete(DeleteBehavior.Restrict);
});
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();
approvalStep.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
return modelBuilder; return modelBuilder;

View File

@@ -1,10 +1,15 @@
namespace Socialize.Api.Modules.Approvals.Data; namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalRequest internal 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;
internal 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;
internal 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,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

@@ -7,9 +7,9 @@ using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record GetApprovalsRequest(Guid ContentItemId); internal record GetApprovalsRequest(Guid ContentItemId);
public record ApprovalDecisionDto( internal record ApprovalDecisionDto(
Guid Id, Guid Id,
Guid ApprovalRequestId, Guid ApprovalRequestId,
string Decision, string Decision,
@@ -20,10 +20,15 @@ public record ApprovalDecisionDto(
string? DecidedByPortraitUrl, string? DecidedByPortraitUrl,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
public record ApprovalRequestDto( internal 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,
@@ -35,7 +40,7 @@ public record ApprovalRequestDto(
DateTimeOffset? CompletedAt, DateTimeOffset? CompletedAt,
IReadOnlyCollection<ApprovalDecisionDto> Decisions); IReadOnlyCollection<ApprovalDecisionDto> Decisions);
public class GetApprovalsHandler( internal class GetApprovalsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>> : Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
@@ -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

@@ -1,35 +1,45 @@
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Observability;
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.Security.Claims;
using System.Text.Json;
namespace Socialize.Api.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest( internal record SubmitApprovalDecisionRequest(
string Decision, string Decision,
string? Comment,
string? ReviewerName, string? ReviewerName,
string? ReviewerEmail); string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator internal class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest> : Validator<SubmitApprovalDecisionRequest>
{ {
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));
} }
} }
public class SubmitApprovalDecisionHandler( internal class SubmitApprovalDecisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter) ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter,
SocializeMetrics metrics)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto> : Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{ {
public override void Configure() public override void Configure()
@@ -58,71 +68,98 @@ 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 ClaimsPrincipal? currentUser = User;
? User.GetAlias() ?? User.GetName() bool isAuthenticated = currentUser?.Identity?.IsAuthenticated == true;
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim(); string decidedByName = isAuthenticated
string decidedByEmail = User?.Identity?.IsAuthenticated == true ? currentUser!.GetAlias() ?? currentUser!.GetName()
? User.GetEmail() : GetReviewerName(request.ReviewerName, approval.ReviewerName);
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim(); string decidedByEmail = isAuthenticated
? currentUser!.GetEmail()
: GetReviewerEmail(request.ReviewerEmail, approval.ReviewerEmail);
ApprovalDecision decision = new() ApprovalDecision decision = new()
{ {
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);
}
metrics.RecordApprovalDecisionSubmitted(approval.WorkspaceId, normalizedDecision);
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 +195,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,
@@ -171,4 +213,18 @@ public class SubmitApprovalDecisionHandler(
await SendOkAsync(dto, ct); await SendOkAsync(dto, ct);
} }
private static string GetReviewerName(string? requestedName, string fallbackName)
{
return string.IsNullOrWhiteSpace(requestedName)
? fallbackName
: requestedName.Trim();
}
private static string GetReviewerEmail(string? requestedEmail, string fallbackEmail)
{
return string.IsNullOrWhiteSpace(requestedEmail)
? fallbackEmail
: requestedEmail.Trim();
}
} }

View File

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

View File

@@ -0,0 +1,56 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
internal static class ApprovalStepTargetTypes
{
public const string Role = "Role";
public const string Membership = "Membership";
public const string Member = "Member";
}
internal static class ApprovalMembershipTargets
{
public const string Team = "Team";
public const string Client = "Client";
}
internal 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;
internal static class ApprovalModes
{
public const string None = "None";
public const string Optional = "Optional";
public const string Required = "Required";
public const string MultiLevel = "Multi-level";
}
internal 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,403 @@
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;
internal record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
internal record ApprovalWorkflowDecisionResult(
bool Succeeded,
string? ErrorMessage,
int StatusCode,
bool IsWorkflowStep);
internal 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);
var approvalDecisionParticipants = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail)
.ToListAsync(ct);
int approvedCount = approvalDecisionParticipants
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
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));
}
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
}

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public class Asset internal class Asset
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }

View File

@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public static class AssetModelConfiguration internal static class AssetModelConfiguration
{ {
public static ModelBuilder ConfigureAssetsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureAssetsModule(this ModelBuilder modelBuilder)
{ {
@@ -21,6 +23,14 @@ public static class AssetModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
asset.HasIndex(x => x.WorkspaceId); asset.HasIndex(x => x.WorkspaceId);
asset.HasIndex(x => x.ContentItemId); asset.HasIndex(x => x.ContentItemId);
asset.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
asset.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<AssetRevision>(revision => modelBuilder.Entity<AssetRevision>(revision =>
@@ -35,6 +45,10 @@ public static class AssetModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
revision.HasIndex(x => x.AssetId); revision.HasIndex(x => x.AssetId);
revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique(); revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique();
revision.HasOne<Asset>()
.WithMany()
.HasForeignKey(x => x.AssetId)
.OnDelete(DeleteBehavior.Cascade);
}); });
return modelBuilder; return modelBuilder;

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public class AssetRevision internal class AssetRevision
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid AssetId { get; set; } public Guid AssetId { get; set; }

View File

@@ -3,17 +3,19 @@ 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;
public record CreateAssetRevisionRequest( internal record CreateAssetRevisionRequest(
string SourceReference, string SourceReference,
string? PreviewUrl, string? PreviewUrl,
string? Notes); string? Notes);
public class CreateAssetRevisionRequestValidator internal class CreateAssetRevisionRequestValidator
: Validator<CreateAssetRevisionRequest> : Validator<CreateAssetRevisionRequest>
{ {
public CreateAssetRevisionRequestValidator() public CreateAssetRevisionRequestValidator()
@@ -24,9 +26,10 @@ public class CreateAssetRevisionRequestValidator
} }
} }
public class CreateAssetRevisionHandler( internal 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,12 +3,14 @@ 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;
public record CreateGoogleDriveAssetRequest( internal record CreateGoogleDriveAssetRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
string AssetType, string AssetType,
@@ -17,7 +19,7 @@ public record CreateGoogleDriveAssetRequest(
string GoogleDriveLink, string GoogleDriveLink,
string? PreviewUrl); string? PreviewUrl);
public class CreateGoogleDriveAssetRequestValidator internal class CreateGoogleDriveAssetRequestValidator
: Validator<CreateGoogleDriveAssetRequest> : Validator<CreateGoogleDriveAssetRequest>
{ {
public CreateGoogleDriveAssetRequestValidator() public CreateGoogleDriveAssetRequestValidator()
@@ -32,9 +34,10 @@ public class CreateGoogleDriveAssetRequestValidator
} }
} }
public class CreateGoogleDriveAssetHandler( internal 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

@@ -5,9 +5,9 @@ using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
public record GetAssetsRequest(Guid ContentItemId); internal record GetAssetsRequest(Guid ContentItemId);
public record AssetRevisionDto( internal record AssetRevisionDto(
Guid Id, Guid Id,
Guid AssetId, Guid AssetId,
int RevisionNumber, int RevisionNumber,
@@ -17,7 +17,7 @@ public record AssetRevisionDto(
Guid? CreatedByUserId, Guid? CreatedByUserId,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
public record AssetDto( internal record AssetDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
@@ -31,7 +31,7 @@ public record AssetDto(
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
IReadOnlyCollection<AssetRevisionDto> Revisions); IReadOnlyCollection<AssetRevisionDto> Revisions);
public class GetAssetsHandler( internal class GetAssetsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>> : Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
@@ -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

@@ -2,7 +2,7 @@ using Socialize.Api.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets; namespace Socialize.Api.Modules.Assets;
public static class DependencyInjection internal static class ModuleRegistration
{ {
public static WebApplicationBuilder AddAssetsModule( public static WebApplicationBuilder AddAssetsModule(
this WebApplicationBuilder builder) this WebApplicationBuilder builder)

View File

@@ -0,0 +1,18 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
internal 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,55 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
#pragma warning disable S1075 // Catalog seed entries intentionally store source URLs.
internal 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;
internal 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;
internal 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;
internal 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;
internal 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,112 @@
using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
internal 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);
}
}
internal record UpsertCalendarSourceRequest(
string Scope,
Guid? OrganizationId,
Guid? WorkspaceId,
string? SourceUrl,
string? CatalogSourceReference,
string DisplayTitle,
string Color,
string Category,
bool IsEnabled,
string? InheritanceMode);
internal 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;
internal 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 &&
EF.Functions.ILike(source.SourceUrl, normalizedUrl)),
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;
internal 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,
};
}
}

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