74 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
583 changed files with 55217 additions and 16281 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
bin/
obj/
**/[Bb]in/
**/[Oo]bj/
**/[Bb]in[\\]*
**/[Oo]bj[\\]*
TestResults/
# Node
@@ -30,6 +34,7 @@ dist/
.vite/
# Local environment files
.env
*.local
.env.local
.env.*.local
@@ -38,5 +43,11 @@ App_Data/
# Local SSL certificates
*.pem
# Ai
# AI agent local state
.agents
.agents/
.codex
.codex/
# Generated local artifacts
.artifacts/

View File

@@ -76,6 +76,47 @@ http://localhost: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
```bash
@@ -90,6 +131,24 @@ cd frontend
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
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 Socialize.Api.Data;
using Socialize.Api.Infrastructure.Observability;
using Socialize.Api.Infrastructure.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication;
@@ -11,7 +12,7 @@ using Microsoft.IdentityModel.Tokens;
namespace Socialize;
public static class DependencyInjection
internal static class ApplicationRegistration
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
{
@@ -20,7 +21,10 @@ public static class DependencyInjection
services.AddHttpContextAccessor();
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.AddScoped<AccessScopeService>();
@@ -70,7 +74,6 @@ public static class DependencyInjection
{
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
{
jwtBearerOptions.Authority = "https://hutopy.com";
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -79,7 +82,7 @@ public static class DependencyInjection
ValidAudience = authJwt["Audience"],
ValidateLifetime = true,
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 =>
{
options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing.");
throw new InvalidOperationException("Authentication:Google:ClientId is required.");
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 =>
{
options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing.");
throw new InvalidOperationException("Authentication:Facebook:ClientId is required.");
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;
public abstract class Entity
internal abstract class Entity
{
public Guid Id { get; init; }
public Guid CreatedBy { get; init; }

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
@@ -9,23 +10,30 @@ using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.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;
namespace Socialize.Api.Data;
public class AppDbContext(
internal class AppDbContext(
DbContextOptions<AppDbContext> 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<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Client> Clients => Set<Client>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
public DbSet<ContentItemActivityEntry> ContentItemActivityEntries => Set<ContentItemActivityEntry>();
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>();
@@ -39,6 +47,14 @@ public class AppDbContext(
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
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)
{
@@ -46,6 +62,7 @@ public class AppDbContext(
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureChannelsModule();
builder.ConfigureClientsModule();
builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule();
@@ -54,5 +71,7 @@ public class AppDbContext(
builder.ConfigureApprovalsModule();
builder.ConfigureNotificationsModule();
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;
public sealed class LocalBlobStorageOptions
internal sealed class LocalBlobStorageOptions
{
public const string SectionName = "LocalBlobStorage";

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class ContentTypes
internal static class ContentTypes
{
private const string ImagePng = "image/png";
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
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;
public interface IBlobStorage
internal interface IBlobStorage
{
/// <summary>
/// Upload a file to blob storage.

View File

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

View File

@@ -1,19 +1,29 @@
using Microsoft.Extensions.Options;
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Observability;
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
public sealed class LocalBlobStorage(
internal sealed class LocalBlobStorage(
IWebHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IOptions<LocalBlobStorageOptions> options,
ILogger<LocalBlobStorage> logger)
ILogger<LocalBlobStorage> logger,
SocializeMetrics metrics)
: IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024;
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;
public async Task<string> UploadFileAsync(
@@ -23,37 +33,51 @@ public sealed class LocalBlobStorage(
string contentType,
CancellationToken ct = default)
{
stream.Position = 0;
if (stream.Length > MaxUploadSize)
try
{
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.");
}
stream.Position = 0;
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);
throw new InvalidOperationException("Unsupported file type.");
metrics.RecordBlobStorageOperation("upload", false);
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(
@@ -61,19 +85,43 @@ public sealed class LocalBlobStorage(
string blobName,
CancellationToken ct = default)
{
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
if (!File.Exists(filePath))
try
{
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()
@@ -106,7 +154,7 @@ public sealed class LocalBlobStorage(
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 ".."))
{
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
@@ -135,7 +183,7 @@ public sealed class LocalBlobStorage(
? "/api/storage"
: requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal)
return normalized.StartsWith('/')
? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}";
}

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.Configuration;
public class WebsiteOptions
internal class WebsiteOptions
{
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;
public class EmailerOptions
internal class EmailerOptions
{
public const string ConfigurationSection = "Emailer";

View File

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

View File

@@ -1,22 +1,24 @@
using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Observability;
namespace Socialize.Api.Infrastructure.Emailer.Services;
public class LoggerEmailSender(ILogger<IEmailSender> logger)
internal class LoggerEmailSender(
ILogger<IEmailSender> logger,
SocializeMetrics metrics)
: 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
{
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
await Task.Delay(1000);
logger.LogInformation("Email sent successfully to {Email}", email);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email to {Email}", email);
throw;
}
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
metrics.RecordEmailDelivery("logger", true);
return Task.CompletedTask;
}
}

View File

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

View File

@@ -3,44 +3,95 @@ using System.Text;
using System.Text.Json;
using Socialize.Api.Infrastructure.Emailer.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Observability;
using Microsoft.Extensions.Options;
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 readonly HttpClient _httpClient;
private readonly SocializeMetrics _metrics;
private readonly EmailerOptions _options;
public ResendEmailSender(
IHttpClientFactory httpClientFactory,
IOptions<EmailerOptions> options)
IOptions<EmailerOptions> options,
SocializeMetrics metrics)
{
_httpClient = httpClientFactory.CreateClient();
_metrics = metrics;
_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 =
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
new AuthenticationHeaderValue("Bearer", apiKey);
_httpClient.DefaultRequestHeaders.Accept.Add(
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);
StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode)
using StringContent content = new(json, Encoding.UTF8, "application/json");
try
{
string body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Resend email failed: {response.StatusCode} - {body}");
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
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;
public static class DependencyInjection
internal static class InfrastructureRegistration
{
public static WebApplicationBuilder AddInfrastructureModule(
this WebApplicationBuilder builder)
@@ -26,8 +26,14 @@ public static class DependencyInjection
builder.Services.Configure<EmailerOptions>(
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
//builder.Services.AddTransient<IEmailSender, EmailSender>();
if (builder.Environment.IsDevelopment())
{
builder.Services.AddTransient<IEmailSender, LoggerEmailSender>();
}
else
{
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
}
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;
public class StripeOptions
internal class StripeOptions
{
public const string ConfigurationSection = "Stripe";

View File

@@ -4,54 +4,55 @@ using Socialize.Api.Modules.Organizations.Services;
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);
}
public bool IsProvider(ClaimsPrincipal user)
public static bool IsProvider(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Provider);
}
public bool IsClient(ClaimsPrincipal user)
public static bool IsClient(ClaimsPrincipal user)
{
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);
}
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{
return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
return CanAccessWorkspace(user, workspaceId) &&
(IsManager(user) || user.GetClientScopeIds().Contains(clientId));
}
public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
return CanAccessClient(user, workspaceId, clientId) &&
(IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
}
public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
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 campaignId)
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) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
@@ -68,7 +69,7 @@ public sealed class AccessScopeService(
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
return user.GetWorkspaceScopeIds().Contains(workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -81,7 +82,7 @@ public sealed class AccessScopeService(
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
return CanManageWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -94,8 +95,7 @@ public sealed class AccessScopeService(
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
return await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
@@ -108,8 +108,7 @@ public sealed class AccessScopeService(
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
@@ -128,8 +127,7 @@ public sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
@@ -149,7 +147,7 @@ public sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -165,7 +163,7 @@ public sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,

View File

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

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Infrastructure.Security;
public static class KnownClaims
internal static class KnownClaims
{
public const string Alias = "alias";
public const string PortraitUrl = "portraitUrl";

View File

@@ -1,5 +1,23 @@
namespace Socialize.Api.Infrastructure.Security;
public class MissingClaimException(
string claimName)
: Exception($"Claim '{claimName}' is missing.");
public class MissingClaimException : Exception
{
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;
// 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 UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private const string Numbers = "0123456789";
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
private static readonly Random Random = new();
public static string Next(
int length = 15,
bool requireNumber = true,
@@ -23,7 +21,7 @@ public static class PasswordGenerator
// Create pools based on the requirements
StringBuilder characterPool = new();
if (requireNumber)
if (requireLowercase)
{
characterPool.Append(LowerLetters);
}
@@ -51,22 +49,22 @@ public static class PasswordGenerator
if (requireLowercase)
{
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)];
password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)];
}
if (requireCapital)
{
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)];
}
if (requireNumber)
{
password[index++] = Numbers[Random.Next(Numbers.Length)];
password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)];
}
if (requireSpecialCharacter)
{
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)];
}
// Fill the rest with the password
@@ -85,7 +83,7 @@ public static class PasswordGenerator
{
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
}
}

View File

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

View File

@@ -6,6 +6,7 @@ using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
@@ -15,18 +16,23 @@ using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data;
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 AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-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 ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
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 HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
@@ -34,23 +40,11 @@ public static class DevelopmentSeedExtensions
private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777");
private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888");
public static async Task<IApplicationBuilder> UseDevelopmentSeedAsync(
this IApplicationBuilder app,
public static async Task<IServiceProvider> SeedTestDataAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default)
{
IHostEnvironment environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
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;
}
using IServiceScope scope = services.CreateScope();
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -59,7 +53,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
username: "manager",
email: "manager@socialize.local",
password: "manager",
password: "Manager1!",
alias: "Northstar Manager",
firstname: "Morgan",
lastname: "Reid",
@@ -75,7 +69,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
username: "client",
email: "client@socialize.local",
password: "client",
password: "Client1!",
alias: "Sofia Martin",
firstname: "Sofia",
lastname: "Martin",
@@ -92,7 +86,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
username: "provider",
email: "provider@socialize.local",
password: "provider",
password: "Provider1!",
alias: "Alex Studio",
firstname: "Alex",
lastname: "Studio",
@@ -110,7 +104,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
username: "dev",
email: "dev@socialize.local",
password: "dev",
password: "Developer1!",
alias: "Socialize Dev",
firstname: "Jo",
lastname: "Bumble",
@@ -133,7 +127,7 @@ public static class DevelopmentSeedExtensions
dbContext,
cancellationToken);
return app;
return services;
}
private static async Task<User> EnsureUserAsync(
@@ -170,7 +164,7 @@ public static class DevelopmentSeedExtensions
if (!createResult.Succeeded)
{
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))}");
}
}
@@ -190,7 +184,7 @@ public static class DevelopmentSeedExtensions
if (!passwordResetResult.Succeeded)
{
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))}");
}
}
@@ -217,13 +211,7 @@ public static class DevelopmentSeedExtensions
await userManager.RemoveClaimAsync(user, claim);
}
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
string persona = GetPersona(roles);
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{
@@ -233,6 +221,26 @@ public static class DevelopmentSeedExtensions
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,
@@ -252,7 +260,8 @@ public static class DevelopmentSeedExtensions
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Collective";
organization.Name = "Northstar Agency";
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(
@@ -309,34 +318,31 @@ public static class DevelopmentSeedExtensions
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = WorkspaceId,
Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = "Northstar Studio";
workspace.Slug = "northstar-studio";
workspace.OrganizationId = OrganizationId;
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);
await UpsertWorkspaceAsync(
dbContext,
WorkspaceId,
OrganizationId,
managerUserId,
"Luma Coffee",
"America/Montreal",
"/images/seed/luma-coffee-logo.svg",
cancellationToken);
await UpsertWorkspaceAsync(
dbContext,
AtlasWorkspaceId,
OrganizationId,
managerUserId,
"Atlas Bakery",
"America/Montreal",
"/images/seed/atlas-bakery-logo.svg",
cancellationToken);
await UpsertClientAsync(
dbContext,
ScopedClientId,
"Luma Coffee",
"Active",
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
"/images/seed/luma-coffee-logo.svg",
"Sofia Martin",
"client@socialize.local",
WorkspaceId,
@@ -346,10 +352,10 @@ public static class DevelopmentSeedExtensions
HiddenClientId,
"Atlas Bakery",
"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@atlasbakery.test",
WorkspaceId,
AtlasWorkspaceId,
cancellationToken);
await UpsertCampaignAsync(
@@ -367,7 +373,7 @@ public static class DevelopmentSeedExtensions
await UpsertCampaignAsync(
dbContext,
HiddenCampaignId,
WorkspaceId,
AtlasWorkspaceId,
HiddenClientId,
"Summer Retention",
"Planned",
@@ -377,6 +383,34 @@ public static class DevelopmentSeedExtensions
"Sequence email and paid social updates together.",
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(
dbContext,
ScopedContentItemId,
@@ -385,7 +419,7 @@ public static class DevelopmentSeedExtensions
ScopedCampaignId,
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
"Luma Coffee Instagram, Luma Coffee TikTok",
"In approval",
DateTimeOffset.UtcNow.AddDays(3),
"v3",
@@ -394,22 +428,22 @@ public static class DevelopmentSeedExtensions
await UpsertContentItemAsync(
dbContext,
HiddenContentItemId,
WorkspaceId,
AtlasWorkspaceId,
HiddenClientId,
HiddenCampaignId,
"Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.",
"Instagram Carousel",
"Atlas Bakery Instagram",
"Draft",
DateTimeOffset.UtcNow.AddDays(10),
"v1",
1,
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-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-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-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-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.", "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.", "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.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
if (asset is null)
@@ -457,8 +491,6 @@ public static class DevelopmentSeedExtensions
comment.AuthorDisplayName = "Sofia Martin";
comment.AuthorEmail = "client@socialize.local";
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);
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
@@ -537,6 +569,38 @@ public static class DevelopmentSeedExtensions
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(
AppDbContext dbContext,
Guid id,
@@ -606,6 +670,37 @@ public static class DevelopmentSeedExtensions
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);
}
private static async Task UpsertContentItemAsync(
AppDbContext dbContext,
Guid id,

View File

@@ -2,7 +2,7 @@ using System.Text.RegularExpressions;
namespace Socialize.Api.Infrastructure.YouTube;
public static class YouTubeUrlHelper
internal static class YouTubeUrlHelper
{
private static readonly Regex VideoIdRegex = new(
@"(?: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

@@ -1,63 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddWorkspaceApprovalConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ApprovalMode",
table: "Workspaces",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "Required");
migrationBuilder.AddColumn<bool>(
name: "LockContentAfterApproval",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SchedulePostsAutomaticallyOnApproval",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SendAutomaticApprovalReminders",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApprovalMode",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "LockContentAfterApproval",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SchedulePostsAutomaticallyOnApproval",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SendAutomaticApprovalReminders",
table: "Workspaces");
}
}
}

View File

@@ -1,51 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddWorkspaceApprovalStepConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkspaceApprovalStepConfigurations",
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(128)", maxLength: 128, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
TargetValue = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
RequiredApproverCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_WorkspaceApprovalStepConfigurations", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId",
table: "WorkspaceApprovalStepConfigurations",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId_SortOrder",
table: "WorkspaceApprovalStepConfigurations",
columns: new[] { "WorkspaceId", "SortOrder" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkspaceApprovalStepConfigurations");
}
}
}

View File

@@ -1,117 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddApprovalWorkflowRuntime : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "WorkflowInstanceId",
table: "ApprovalRequests",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkflowStepRequiredApproverCount",
table: "ApprovalRequests",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkflowStepSortOrder",
table: "ApprovalRequests",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WorkflowStepTargetType",
table: "ApprovalRequests",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WorkflowStepTargetValue",
table: "ApprovalRequests",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.CreateTable(
name: "ApprovalWorkflowInstances",
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),
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ApprovalMode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartedAt = 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_ApprovalWorkflowInstances", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkflowInstanceId",
table: "ApprovalRequests",
column: "WorkflowInstanceId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId",
table: "ApprovalWorkflowInstances",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId_State",
table: "ApprovalWorkflowInstances",
columns: new[] { "ContentItemId", "State" },
unique: true,
filter: "\"State\" = 'Pending'");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_WorkspaceId",
table: "ApprovalWorkflowInstances",
column: "WorkspaceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalWorkflowInstances");
migrationBuilder.DropIndex(
name: "IX_ApprovalRequests_WorkflowInstanceId",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowInstanceId",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepRequiredApproverCount",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepSortOrder",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepTargetType",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepTargetValue",
table: "ApprovalRequests");
}
}
}

View File

@@ -1,117 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class RenameProjectsToCampaigns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "Projects",
newName: "Campaigns");
migrationBuilder.DropPrimaryKey(
name: "PK_Projects",
table: "Campaigns");
migrationBuilder.AddPrimaryKey(
name: "PK_Campaigns",
table: "Campaigns",
column: "Id");
migrationBuilder.RenameIndex(
name: "IX_Projects_WorkspaceId",
table: "Campaigns",
newName: "IX_Campaigns_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_Projects_ClientId_Name",
table: "Campaigns",
newName: "IX_Campaigns_ClientId_Name");
migrationBuilder.RenameIndex(
name: "IX_Projects_ClientId",
table: "Campaigns",
newName: "IX_Campaigns_ClientId");
migrationBuilder.RenameColumn(
name: "ProjectName",
table: "FeedbackReports",
newName: "CampaignName");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "FeedbackReports",
newName: "CampaignId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "ContentItems",
newName: "CampaignId");
migrationBuilder.RenameIndex(
name: "IX_ContentItems_ProjectId",
table: "ContentItems",
newName: "IX_ContentItems_CampaignId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Campaigns",
table: "Campaigns");
migrationBuilder.AddPrimaryKey(
name: "PK_Projects",
table: "Campaigns",
column: "Id");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_WorkspaceId",
table: "Campaigns",
newName: "IX_Projects_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_ClientId_Name",
table: "Campaigns",
newName: "IX_Projects_ClientId_Name");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_ClientId",
table: "Campaigns",
newName: "IX_Projects_ClientId");
migrationBuilder.RenameTable(
name: "Campaigns",
newName: "Projects");
migrationBuilder.RenameColumn(
name: "CampaignName",
table: "FeedbackReports",
newName: "ProjectName");
migrationBuilder.RenameColumn(
name: "CampaignId",
table: "FeedbackReports",
newName: "ProjectId");
migrationBuilder.RenameColumn(
name: "CampaignId",
table: "ContentItems",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_ContentItems_CampaignId",
table: "ContentItems",
newName: "IX_ContentItems_ProjectId");
}
}
}

View File

@@ -1,126 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddOrganizations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
table: "Workspaces",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "Organizations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
OwnerUserId = 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_Organizations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OrganizationMemberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMemberships", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMemberships_Organizations_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql(
"""
INSERT INTO "Organizations" ("Id", "Name", "OwnerUserId", "CreatedAt")
VALUES ('99999999-9999-9999-9999-999999999999', 'Northstar Collective', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', CURRENT_TIMESTAMP);
UPDATE "Workspaces"
SET "OrganizationId" = '99999999-9999-9999-9999-999999999999'
WHERE "OrganizationId" = '00000000-0000-0000-0000-000000000000';
INSERT INTO "OrganizationMemberships" ("Id", "OrganizationId", "UserId", "Role", "CreatedAt")
VALUES ('99999999-9999-9999-9999-000000000001', '99999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Owner', CURRENT_TIMESTAMP);
""");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId",
table: "OrganizationMemberships",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId_UserId",
table: "OrganizationMemberships",
columns: new[] { "OrganizationId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_UserId",
table: "OrganizationMemberships",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Organizations_OwnerUserId",
table: "Organizations",
column: "OwnerUserId");
migrationBuilder.AddForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces",
column: "OrganizationId",
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces");
migrationBuilder.DropTable(
name: "OrganizationMemberships");
migrationBuilder.DropTable(
name: "Organizations");
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "OrganizationId",
table: "Workspaces");
}
}
}

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260504195518_AddOrganizations")]
partial class AddOrganizations
[Migration("20260507143849_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -441,6 +441,326 @@ namespace Socialize.Api.Migrations
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")
@@ -494,6 +814,48 @@ namespace Socialize.Api.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -550,6 +912,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.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")
.IsRequired()
.HasMaxLength(256)
@@ -576,15 +961,9 @@ namespace Socialize.Api.Migrations
.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");
@@ -665,6 +1044,62 @@ namespace Socialize.Api.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -1217,6 +1652,10 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -1314,11 +1753,6 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
@@ -1330,9 +1764,6 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});
@@ -1428,6 +1859,15 @@ namespace Socialize.Api.Migrations
.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 =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260501191447_RenameProjectsToCampaigns")]
partial class RenameProjectsToCampaigns
[Migration("20260507185052_AddMissingDomainForeignKeys")]
partial class AddMissingDomainForeignKeys
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -441,6 +441,326 @@ namespace Socialize.Api.Migrations
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")
@@ -494,6 +814,48 @@ namespace Socialize.Api.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -550,6 +912,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.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")
.IsRequired()
.HasMaxLength(256)
@@ -576,15 +961,9 @@ namespace Socialize.Api.Migrations
.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");
@@ -665,6 +1044,62 @@ namespace Socialize.Api.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -927,6 +1362,12 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("ClientId");
b.HasIndex("ContentItemId");
b.HasIndex("LastActivityAt");
b.HasIndex("ReporterUserId");
@@ -1206,6 +1647,70 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
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()
.HasMaxLength(64)
.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")
@@ -1238,6 +1743,9 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
@@ -1251,11 +1759,6 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
@@ -1263,10 +1766,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces", (string)null);
});
@@ -1363,6 +1865,190 @@ namespace Socialize.Api.Migrations
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", null)
.WithMany()
.HasForeignKey("ApprovalRequestId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", null)
.WithMany()
.HasForeignKey("WorkflowInstanceId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b =>
{
b.HasOne("Socialize.Api.Modules.Assets.Data.Asset", null)
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.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.Campaigns.Data.Campaign", b =>
{
b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b =>
{
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Comments.Data.Comment", null)
.WithMany()
.HasForeignKey("ParentCommentId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b =>
{
b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null)
.WithMany()
.HasForeignKey("CampaignId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1385,6 +2071,29 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null)
.WithMany()
.HasForeignKey("CampaignId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1407,6 +2116,47 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b =>
{
b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null)
.WithMany()
.HasForeignKey("ContentItemId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
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.Workspaces.Data.WorkspaceInvite", b =>
{
b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null)
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");

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");
}
}
}

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;
public class ApprovalDecision
internal class ApprovalDecision
{
public Guid Id { get; init; }
public Guid ApprovalRequestId { get; set; }

View File

@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Data;
public static class ApprovalModelConfiguration
internal static class ApprovalModelConfiguration
{
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
{
@@ -20,6 +22,14 @@ public static class ApprovalModelConfiguration
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 =>
@@ -40,6 +50,18 @@ public static class ApprovalModelConfiguration
approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
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 =>
@@ -54,6 +76,10 @@ public static class ApprovalModelConfiguration
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalDecision.HasIndex(x => x.ApprovalRequestId);
approvalDecision.HasOne<ApprovalRequest>()
.WithMany()
.HasForeignKey(x => x.ApprovalRequestId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
@@ -69,6 +95,10 @@ public static class ApprovalModelConfiguration
.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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,25 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Observability;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
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;
public record SubmitApprovalDecisionRequest(
internal record SubmitApprovalDecisionRequest(
string Decision,
string? ReviewerName,
string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator
internal class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest>
{
public SubmitApprovalDecisionRequestValidator()
@@ -29,11 +33,13 @@ public class SubmitApprovalDecisionRequestValidator
}
}
public class SubmitApprovalDecisionHandler(
internal class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter,
SocializeMetrics metrics)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
public override void Configure()
@@ -76,12 +82,14 @@ public class SubmitApprovalDecisionHandler(
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
string decidedByEmail = User?.Identity?.IsAuthenticated == true
? User.GetEmail()
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
ClaimsPrincipal? currentUser = User;
bool isAuthenticated = currentUser?.Identity?.IsAuthenticated == true;
string decidedByName = isAuthenticated
? currentUser!.GetAlias() ?? currentUser!.GetName()
: GetReviewerName(request.ReviewerName, approval.ReviewerName);
string decidedByEmail = isAuthenticated
? currentUser!.GetEmail()
: GetReviewerEmail(request.ReviewerEmail, approval.ReviewerEmail);
ApprovalDecision decision = new()
{
@@ -120,6 +128,24 @@ public class SubmitApprovalDecisionHandler(
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
await activityWriter.WriteAsync(
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,
@@ -133,6 +159,7 @@ public class SubmitApprovalDecisionHandler(
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
}
metrics.RecordApprovalDecisionSubmitted(approval.WorkspaceId, normalizedDecision);
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
@@ -186,4 +213,18 @@ public class SubmitApprovalDecisionHandler(
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

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

View File

@@ -2,20 +2,20 @@ using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalStepTargetTypes
internal static class ApprovalStepTargetTypes
{
public const string Role = "Role";
public const string Membership = "Membership";
public const string Member = "Member";
}
public static class ApprovalMembershipTargets
internal static class ApprovalMembershipTargets
{
public const string Team = "Team";
public const string Client = "Client";
}
public static class ApprovalStepConfigurationRules
internal static class ApprovalStepConfigurationRules
{
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
{

View File

@@ -2,7 +2,7 @@ using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalModes
internal static class ApprovalModes
{
public const string None = "None";
public const string Optional = "Optional";
@@ -10,7 +10,7 @@ public static class ApprovalModes
public const string MultiLevel = "Multi-level";
}
public static class ApprovalWorkflowRules
internal static class ApprovalWorkflowRules
{
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
{

View File

@@ -11,15 +11,15 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Services;
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
internal record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
public record ApprovalWorkflowDecisionResult(
internal record ApprovalWorkflowDecisionResult(
bool Succeeded,
string? ErrorMessage,
int StatusCode,
bool IsWorkflowStep);
public class ApprovalWorkflowRuntimeService(
internal class ApprovalWorkflowRuntimeService(
AppDbContext dbContext,
INotificationEventWriter notificationEventWriter)
{
@@ -145,13 +145,15 @@ public class ApprovalWorkflowRuntimeService(
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions
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.ToLower())
.Distinct()
.CountAsync(ct);
: candidate.DecidedByEmail)
.ToListAsync(ct);
int approvedCount = approvalDecisionParticipants
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
@@ -394,7 +396,7 @@ public class ApprovalWorkflowRuntimeService(
private static string CreateAccessToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
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;
public class Asset
internal class Asset
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }

View File

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

View File

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

View File

@@ -3,17 +3,19 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateAssetRevisionRequest(
internal record CreateAssetRevisionRequest(
string SourceReference,
string? PreviewUrl,
string? Notes);
public class CreateAssetRevisionRequestValidator
internal class CreateAssetRevisionRequestValidator
: Validator<CreateAssetRevisionRequest>
{
public CreateAssetRevisionRequestValidator()
@@ -24,9 +26,10 @@ public class CreateAssetRevisionRequestValidator
}
}
public class CreateAssetRevisionHandler(
internal class CreateAssetRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
{
@@ -78,6 +81,25 @@ public class CreateAssetRevisionHandler(
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(
new NotificationEventWriteModel(
asset.WorkspaceId,

View File

@@ -3,12 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateGoogleDriveAssetRequest(
internal record CreateGoogleDriveAssetRequest(
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
@@ -17,7 +19,7 @@ public record CreateGoogleDriveAssetRequest(
string GoogleDriveLink,
string? PreviewUrl);
public class CreateGoogleDriveAssetRequestValidator
internal class CreateGoogleDriveAssetRequestValidator
: Validator<CreateGoogleDriveAssetRequest>
{
public CreateGoogleDriveAssetRequestValidator()
@@ -32,9 +34,10 @@ public class CreateGoogleDriveAssetRequestValidator
}
}
public class CreateGoogleDriveAssetHandler(
internal class CreateGoogleDriveAssetHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
{
@@ -93,6 +96,25 @@ public class CreateGoogleDriveAssetHandler(
dbContext.AssetRevisions.Add(revision);
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(
new NotificationEventWriteModel(
asset.WorkspaceId,

View File

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

View File

@@ -2,7 +2,7 @@ using Socialize.Api.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets;
public static class DependencyInjection
internal static class ModuleRegistration
{
public static WebApplicationBuilder AddAssetsModule(
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; }
}

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