Compare commits
84 Commits
c49f03ec06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eaba6983d | |||
| 1826045b99 | |||
| 85054d2113 | |||
| 39a68a71cd | |||
| d634648bed | |||
| 6ff54ab7e0 | |||
| ba9ce9b2d3 | |||
| 9b4ebef19a | |||
| 26f532c49b | |||
| 4ac8c039ed | |||
| 01a44abc9c | |||
| afcdd1ace1 | |||
| 831ffde411 | |||
| 5a798d6650 | |||
| ca68132546 | |||
| fc760736f8 | |||
| 87530bed84 | |||
| ebfa37f8cd | |||
| 581d286a1c | |||
| 030bf1b4ef | |||
| 986c7efea6 | |||
| 8bcff96821 | |||
| 1ca6ab7117 | |||
| e81c9f42c9 | |||
| c527011646 | |||
| 0b7edb1b7f | |||
| dcfdce1ec6 | |||
| 2eb54b9228 | |||
| 9c011f1a1e | |||
| b6eb348605 | |||
| 7a8a0a44bf | |||
| 6d92119c9c | |||
| db16e79d9f | |||
| 4aaa1a7f90 | |||
| 6ac05e1a10 | |||
| 9768a37252 | |||
| 98c76a7d88 | |||
| 49e2ca1774 | |||
| e9fb1c5ee0 | |||
| 57abe57bc7 | |||
| 9022fa7d93 | |||
| d1621ecb36 | |||
| 6e417312f9 | |||
| 918136aae2 | |||
| 0521d91240 | |||
| c18a223759 | |||
| 298c46de7c | |||
| 2d22fd6e04 | |||
| ef323c291f | |||
| 4eb0fbc22b | |||
| afe22949c5 | |||
| ebb87b286f | |||
| f1da3a44de | |||
| 419dbf0185 | |||
| 909ae6f092 | |||
| a97ff2dc38 | |||
| 7a862a202a | |||
| 1ae3188d34 | |||
| fb7811c469 | |||
| 0a6d730ca0 | |||
| d2d3bee975 | |||
| 78de068cd1 | |||
| 1965dc2c9e | |||
| f0d635ef21 | |||
| d59d667796 | |||
| 5c0e40db7e | |||
| dc9a980958 | |||
| c40653b2b7 | |||
| f240d32ce6 | |||
| 4775e35b3c | |||
| a7535d460d | |||
| db344eebac | |||
| 9699c4d55c | |||
| c183626a7a | |||
| 5db182dda9 | |||
| 6296a91c3d | |||
| 91b7f96fdb | |||
| 88c4c23ce1 | |||
| a96b3c897c | |||
| a437bfcfc3 | |||
| b7b282a71a | |||
| 6083797eb1 | |||
| ecbd3daa1b | |||
| b66c10b681 |
75
.gitea/workflows/deploy-socialize.yml
Normal file
75
.gitea/workflows/deploy-socialize.yml
Normal 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'
|
||||
39
.github/workflows/backend-ci.yml
vendored
39
.github/workflows/backend-ci.yml
vendored
@@ -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/'
|
||||
38
.github/workflows/frontend-ci.yml
vendored
38
.github/workflows/frontend-ci.yml
vendored
@@ -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
13
.gitignore
vendored
@@ -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/
|
||||
|
||||
59
README.md
59
README.md
@@ -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:
|
||||
|
||||
82
backend/.github/workflows/build.yml
vendored
82
backend/.github/workflows/build.yml
vendored
@@ -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
|
||||
42
backend/.github/workflows/cicd.yml
vendored
42
backend/.github/workflows/cicd.yml
vendored
@@ -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
|
||||
107
backend/.github/workflows/deploy.yml
vendored
107
backend/.github/workflows/deploy.yml
vendored
@@ -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
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -10,16 +10,21 @@ 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>();
|
||||
@@ -28,6 +33,7 @@ public class AppDbContext(
|
||||
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>();
|
||||
@@ -41,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)
|
||||
{
|
||||
@@ -57,5 +71,7 @@ public class AppDbContext(
|
||||
builder.ConfigureApprovalsModule();
|
||||
builder.ConfigureNotificationsModule();
|
||||
builder.ConfigureFeedbackModule();
|
||||
builder.ConfigureCalendarIntegrationsModule();
|
||||
builder.ConfigureReleaseCommunicationsModule();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
|
||||
public sealed class LocalBlobStorageOptions
|
||||
internal sealed class LocalBlobStorageOptions
|
||||
{
|
||||
public const string SectionName = "LocalBlobStorage";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -2,14 +2,30 @@
|
||||
|
||||
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";
|
||||
private const string ImageJpg = "image/jpg";
|
||||
private const string ImageGif = "image/gif";
|
||||
private const string ImageWebp = "image/webp";
|
||||
private const string VideoMp4 = "video/mp4";
|
||||
private const string VideoWebm = "video/webm";
|
||||
private const string VideoQuickTime = "video/quicktime";
|
||||
private const string TextHtml = "text/html";
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
|
||||
private static readonly HashSet<string> AllowedContentTypes =
|
||||
[
|
||||
ImagePng,
|
||||
ImageJpeg,
|
||||
ImageJpg,
|
||||
ImageGif,
|
||||
ImageWebp,
|
||||
VideoMp4,
|
||||
VideoWebm,
|
||||
VideoQuickTime,
|
||||
TextHtml,
|
||||
];
|
||||
|
||||
public static bool IsAllowed(
|
||||
string contentType,
|
||||
@@ -37,8 +53,34 @@ public static class ContentTypes
|
||||
return true;
|
||||
}
|
||||
|
||||
// GIF file signatures: GIF87a and GIF89a
|
||||
if (buffer[0] == 0x47 && buffer[1] == 0x49 && buffer[2] == 0x46 &&
|
||||
buffer[3] == 0x38 && (buffer[4] == 0x37 || buffer[4] == 0x39) && buffer[5] == 0x61)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebP files are RIFF containers with a WEBP marker.
|
||||
if (buffer[0] == 0x52 && buffer[1] == 0x49 && buffer[2] == 0x46 && buffer[3] == 0x46 &&
|
||||
buffer[8] == 0x57 && buffer[9] == 0x45 && buffer[10] == 0x42 && buffer[11] == 0x50)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MP4/MOV containers expose an ftyp box near the beginning.
|
||||
if (buffer[4] == 0x66 && buffer[5] == 0x74 && buffer[6] == 0x79 && buffer[7] == 0x70)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebM files use the EBML header.
|
||||
if (buffer[0] == 0x1A && buffer[1] == 0x45 && buffer[2] == 0xDF && buffer[3] == 0xA3)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public interface IBlobStorage
|
||||
internal interface IBlobStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Upload a file to blob storage.
|
||||
@@ -29,4 +29,9 @@ public interface IBlobStorage
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task DeleteFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
@@ -22,6 +32,8 @@ public sealed class LocalBlobStorage(
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
@@ -46,20 +58,34 @@ public sealed class LocalBlobStorage(
|
||||
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);
|
||||
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
||||
metrics.RecordBlobStorageOperation("upload", true);
|
||||
|
||||
return fileUri;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
|
||||
@@ -72,9 +98,53 @@ public sealed class LocalBlobStorage(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_ = ct;
|
||||
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
|
||||
string metadataPath = GetContentTypeMetadataPath(filePath);
|
||||
if (File.Exists(metadataPath))
|
||||
{
|
||||
File.Delete(metadataPath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal string GetRootPath()
|
||||
{
|
||||
@@ -106,7 +176,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 +205,7 @@ public sealed class LocalBlobStorage(
|
||||
? "/api/storage"
|
||||
: requestPath.Trim();
|
||||
|
||||
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||
return normalized.StartsWith('/')
|
||||
? normalized.TrimEnd('/')
|
||||
: $"/{normalized.TrimEnd('/')}";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
internal class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Socialize.Api.Infrastructure.Development;
|
||||
|
||||
public record DevelopmentSeedOptions
|
||||
{
|
||||
public const string SectionName = "DevelopmentSeed";
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
|
||||
public class EmailerOptions
|
||||
internal class EmailerOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Emailer";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
|
||||
public interface IEmailSender
|
||||
internal interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string email, string subject, string message);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,38 +3,59 @@ 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);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -42,5 +63,35 @@ public class ResendEmailSender : IEmailSender
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddTransient<IEmailSender, LoggerEmailSender>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||
//builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -146,32 +144,55 @@ public sealed class AccessScopeService(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid campaignId,
|
||||
Guid? campaignId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return IsManager(user)
|
||||
if (!campaignId.HasValue)
|
||||
{
|
||||
return IsManager(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
|
||||
|| IsProvider(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
|
||||
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanReviewContentAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid campaignId,
|
||||
Guid? campaignId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return IsManager(user)
|
||||
if (!campaignId.HasValue)
|
||||
{
|
||||
return IsManager(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|
||||
|| IsProvider(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
|
||||
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -15,12 +15,14 @@ using Socialize.Api.Modules.Campaigns.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Services;
|
||||
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");
|
||||
@@ -39,23 +41,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>();
|
||||
|
||||
@@ -64,7 +54,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",
|
||||
@@ -80,7 +70,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",
|
||||
@@ -97,7 +87,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",
|
||||
@@ -115,7 +105,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",
|
||||
@@ -138,7 +128,7 @@ public static class DevelopmentSeedExtensions
|
||||
dbContext,
|
||||
cancellationToken);
|
||||
|
||||
return app;
|
||||
return services;
|
||||
}
|
||||
|
||||
private static async Task<User> EnsureUserAsync(
|
||||
@@ -175,7 +165,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))}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +185,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))}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +212,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)]))
|
||||
{
|
||||
@@ -238,6 +222,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,
|
||||
@@ -258,6 +262,11 @@ public static class DevelopmentSeedExtensions
|
||||
}
|
||||
|
||||
organization.Name = "Northstar Agency";
|
||||
organization.IsGoogleDriveDamEnabled = true;
|
||||
organization.GoogleDriveRootFolderId = "dev-socialize-dam-root";
|
||||
organization.GoogleDriveRootFolderName = "Socialize DAM";
|
||||
organization.GoogleDriveRootFolderUrl = "https://drive.google.com/drive/folders/dev-socialize-dam-root";
|
||||
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
|
||||
organization.OwnerUserId = managerUserId;
|
||||
|
||||
await UpsertOrganizationMembershipAsync(
|
||||
@@ -461,6 +470,7 @@ public static class DevelopmentSeedExtensions
|
||||
asset.DisplayName = "Spring launch cut";
|
||||
asset.GoogleDriveFileId = "dev-socialize-demo";
|
||||
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
|
||||
asset.GoogleDriveWorkspaceFolderPath = "Socialize DAM/luma-coffee";
|
||||
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
|
||||
asset.CurrentRevisionNumber = 2;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
@@ -487,8 +497,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);
|
||||
@@ -585,6 +593,7 @@ public static class DevelopmentSeedExtensions
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Slug = string.Empty,
|
||||
TimeZone = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
@@ -592,6 +601,12 @@ public static class DevelopmentSeedExtensions
|
||||
}
|
||||
|
||||
workspace.Name = name;
|
||||
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
|
||||
dbContext,
|
||||
organizationId,
|
||||
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
|
||||
workspace.Id,
|
||||
cancellationToken);
|
||||
workspace.OrganizationId = organizationId;
|
||||
workspace.OwnerUserId = ownerUserId;
|
||||
workspace.TimeZone = timeZone;
|
||||
@@ -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})",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddChannels : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Channels",
|
||||
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),
|
||||
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ExternalUrl = 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_Channels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Channels_WorkspaceId",
|
||||
table: "Channels",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Channels_WorkspaceId_Network_Name",
|
||||
table: "Channels",
|
||||
columns: new[] { "WorkspaceId", "Network", "Name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ using Socialize.Api.Data;
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260505162446_AddChannels")]
|
||||
partial class AddChannels
|
||||
[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")
|
||||
@@ -592,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)
|
||||
@@ -618,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");
|
||||
|
||||
@@ -707,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")
|
||||
@@ -1259,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)
|
||||
@@ -1462,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")
|
||||
@@ -4,10 +4,13 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
#pragma warning disable CA1861 // Generated migration seed arrays are not runtime hot paths.
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
internal partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
@@ -162,6 +165,56 @@ namespace Socialize.Api.Migrations
|
||||
table.PrimaryKey("PK_Assets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CalendarCatalogEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
Country = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
|
||||
Region = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
Language = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
CultureOrReligion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
ProviderName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
TrustLevel = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
DefaultColor = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CalendarCatalogEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CalendarSources",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CatalogSourceReference = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
DisplayTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Color = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
InheritanceMode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
LastSuccessfulSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastAttemptedSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastSyncError = 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"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CalendarSources", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Campaigns",
|
||||
columns: table => new
|
||||
@@ -182,6 +235,23 @@ namespace Socialize.Api.Migrations
|
||||
table.PrimaryKey("PK_Campaigns", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Channels",
|
||||
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),
|
||||
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ExternalUrl = 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_Channels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Clients",
|
||||
columns: table => new
|
||||
@@ -213,15 +283,40 @@ namespace Socialize.Api.Migrations
|
||||
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)
|
||||
AttachmentFileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
AttachmentContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
AttachmentSizeBytes = table.Column<long>(type: "bigint", nullable: true),
|
||||
AttachmentBlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
AttachmentBlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
AttachmentBlobUrl = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Comments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContentItemActivityEntries",
|
||||
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),
|
||||
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),
|
||||
Summary = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
ActorUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ContentItemActivityEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContentItemRevisions",
|
||||
columns: table => new
|
||||
@@ -329,6 +424,7 @@ namespace Socialize.Api.Migrations
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
LogoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
@@ -337,6 +433,23 @@ namespace Socialize.Api.Migrations
|
||||
table.PrimaryKey("PK_Organizations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserCalendarExportFeeds",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(96)", maxLength: 96, nullable: true),
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
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, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserCalendarExportFeeds", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkspaceApprovalStepConfigurations",
|
||||
columns: table => new
|
||||
@@ -478,6 +591,41 @@ namespace Socialize.Api.Migrations
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CalendarEvents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CalendarSourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceEventUid = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
IsAllDay = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsFloatingTime = table.Column<bool>(type: "boolean", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
StartLocalDateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
EndLocalDateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
StartUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
EndUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
TimeZoneId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
RecurrenceId = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
Location = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
SourceLastModifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ImportedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CalendarEvents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CalendarEvents_CalendarSources_CalendarSourceId",
|
||||
column: x => x.CalendarSourceId,
|
||||
principalTable: "CalendarSources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackActivityEntries",
|
||||
columns: table => new
|
||||
@@ -620,6 +768,16 @@ namespace Socialize.Api.Migrations
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "CalendarCatalogEntries",
|
||||
columns: new[] { "Id", "Category", "Country", "CultureOrReligion", "DefaultColor", "Description", "Language", "ProviderName", "Region", "SourceUrl", "Title", "TrustLevel" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("10000000-0000-0000-0000-000000000001"), "public-holiday", "US", null, "#2F80ED", "Federal public holiday calendar for the United States.", "en", "Nager.Date", null, "https://date.nager.at/api/v3/PublicHolidays/2026/US", "United States Public Holidays", "Verified" },
|
||||
{ new Guid("10000000-0000-0000-0000-000000000002"), "public-holiday", "CA", null, "#2F80ED", "Public holiday calendar for Canada.", "en", "Nager.Date", null, "https://date.nager.at/api/v3/PublicHolidays/2026/CA", "Canada Public Holidays", "Verified" },
|
||||
{ new Guid("10000000-0000-0000-0000-000000000003"), "marketing-moment", null, null, "#9B51E0", "Common retail, awareness, and social planning moments.", "en", "Socialize", null, "https://example.com/socialize/marketing-moments.ics", "Common Marketing Moments", "Maintained" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApprovalDecisions_ApprovalRequestId",
|
||||
table: "ApprovalDecisions",
|
||||
@@ -720,6 +878,52 @@ namespace Socialize.Api.Migrations
|
||||
table: "Assets",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarCatalogEntries_Category",
|
||||
table: "CalendarCatalogEntries",
|
||||
column: "Category");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarCatalogEntries_Country",
|
||||
table: "CalendarCatalogEntries",
|
||||
column: "Country");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarCatalogEntries_ProviderName",
|
||||
table: "CalendarCatalogEntries",
|
||||
column: "ProviderName");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarEvents_CalendarSourceId",
|
||||
table: "CalendarEvents",
|
||||
column: "CalendarSourceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarEvents_CalendarSourceId_SourceEventUid_StartDate",
|
||||
table: "CalendarEvents",
|
||||
columns: new[] { "CalendarSourceId", "SourceEventUid", "StartDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarSources_OrganizationId",
|
||||
table: "CalendarSources",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarSources_Scope",
|
||||
table: "CalendarSources",
|
||||
column: "Scope");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarSources_UserId",
|
||||
table: "CalendarSources",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CalendarSources_WorkspaceId",
|
||||
table: "CalendarSources",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Campaigns_ClientId",
|
||||
table: "Campaigns",
|
||||
@@ -736,6 +940,17 @@ namespace Socialize.Api.Migrations
|
||||
table: "Campaigns",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Channels_WorkspaceId",
|
||||
table: "Channels",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Channels_WorkspaceId_Network_Name",
|
||||
table: "Channels",
|
||||
columns: new[] { "WorkspaceId", "Network", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Clients_WorkspaceId",
|
||||
table: "Clients",
|
||||
@@ -762,6 +977,21 @@ namespace Socialize.Api.Migrations
|
||||
table: "Comments",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemActivityEntries_ContentItemId",
|
||||
table: "ContentItemActivityEntries",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemActivityEntries_ContentItemId_CreatedAt",
|
||||
table: "ContentItemActivityEntries",
|
||||
columns: new[] { "ContentItemId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemActivityEntries_WorkspaceId",
|
||||
table: "ContentItemActivityEntries",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemRevisions_ContentItemId",
|
||||
table: "ContentItemRevisions",
|
||||
@@ -901,6 +1131,18 @@ namespace Socialize.Api.Migrations
|
||||
table: "Organizations",
|
||||
column: "OwnerUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserCalendarExportFeeds_TokenHash",
|
||||
table: "UserCalendarExportFeeds",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserCalendarExportFeeds_UserId",
|
||||
table: "UserCalendarExportFeeds",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId",
|
||||
table: "WorkspaceApprovalStepConfigurations",
|
||||
@@ -966,15 +1208,27 @@ namespace Socialize.Api.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "Assets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CalendarCatalogEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CalendarEvents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Campaigns");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Channels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Clients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Comments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContentItemActivityEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContentItemRevisions");
|
||||
|
||||
@@ -999,6 +1253,9 @@ namespace Socialize.Api.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrganizationMemberships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserCalendarExportFeeds");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkspaceApprovalStepConfigurations");
|
||||
|
||||
@@ -1014,6 +1271,9 @@ namespace Socialize.Api.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CalendarSources");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackReports");
|
||||
|
||||
@@ -12,8 +12,8 @@ using Socialize.Api.Data;
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260505013232_Initial")]
|
||||
partial class Initial
|
||||
[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");
|
||||
@@ -1217,6 +1658,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)
|
||||
@@ -1420,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")
|
||||
@@ -1442,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")
|
||||
@@ -1464,6 +2116,20 @@ 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)
|
||||
@@ -1482,6 +2148,15 @@ namespace Socialize.Api.Migrations
|
||||
.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");
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2382
backend/src/Socialize.Api/Migrations/20260508003746_LocalizeOrganizationMembershipTiers.Designer.cs
generated
Normal file
2382
backend/src/Socialize.Api/Migrations/20260508003746_LocalizeOrganizationMembershipTiers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2657
backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs
generated
Normal file
2657
backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddGoogleDriveDamFoundation : Migration
|
||||
{
|
||||
private static readonly string[] WorkspaceOrganizationSlugIndexColumns =
|
||||
[
|
||||
"OrganizationId",
|
||||
"Slug",
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Slug",
|
||||
table: "Workspaces",
|
||||
type: "character varying(96)",
|
||||
maxLength: 96,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
WITH normalized AS (
|
||||
SELECT
|
||||
"Id",
|
||||
"OrganizationId",
|
||||
COALESCE(
|
||||
NULLIF(
|
||||
trim(both '-' from lower(regexp_replace(trim("Name"), '[^a-zA-Z0-9]+', '-', 'g'))),
|
||||
''
|
||||
),
|
||||
'workspace'
|
||||
) AS "BaseSlug"
|
||||
FROM "Workspaces"
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
"Id",
|
||||
"BaseSlug",
|
||||
row_number() OVER (PARTITION BY "OrganizationId", "BaseSlug" ORDER BY "Id") AS "SlugIndex"
|
||||
FROM normalized
|
||||
)
|
||||
UPDATE "Workspaces"
|
||||
SET "Slug" = left(
|
||||
CASE
|
||||
WHEN numbered."SlugIndex" = 1 THEN numbered."BaseSlug"
|
||||
ELSE
|
||||
left(
|
||||
numbered."BaseSlug",
|
||||
96 - length('-' || numbered."SlugIndex"::text)
|
||||
) || '-' || numbered."SlugIndex"
|
||||
END,
|
||||
96
|
||||
)
|
||||
FROM numbered
|
||||
WHERE "Workspaces"."Id" = numbered."Id";
|
||||
""");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveRootFolderId",
|
||||
table: "Organizations",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveRootFolderName",
|
||||
table: "Organizations",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveRootFolderUrl",
|
||||
table: "Organizations",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsGoogleDriveDamEnabled",
|
||||
table: "Organizations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveWorkspaceFolderPath",
|
||||
table: "Assets",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Workspaces_OrganizationId_Slug",
|
||||
table: "Workspaces",
|
||||
columns: WorkspaceOrganizationSlugIndexColumns,
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Workspaces_OrganizationId_Slug",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Slug",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveRootFolderId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveRootFolderName",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveRootFolderUrl",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsGoogleDriveDamEnabled",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveWorkspaceFolderPath",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
2641
backend/src/Socialize.Api/Migrations/20260508181218_AddGoogleDriveDamCredentials.Designer.cs
generated
Normal file
2641
backend/src/Socialize.Api/Migrations/20260508181218_AddGoogleDriveDamCredentials.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddGoogleDriveDamCredentials : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveAccessToken",
|
||||
table: "Organizations",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveClientId",
|
||||
table: "Organizations",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveClientSecret",
|
||||
table: "Organizations",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleDriveRefreshToken",
|
||||
table: "Organizations",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "ContentItemId",
|
||||
table: "Assets",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveAccessToken",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveClientId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveClientSecret",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GoogleDriveRefreshToken",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "ContentItemId",
|
||||
table: "Assets",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: Guid.Empty,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
2645
backend/src/Socialize.Api/Migrations/20260508203754_AddWorkspaceDamBlobObjectNames.Designer.cs
generated
Normal file
2645
backend/src/Socialize.Api/Migrations/20260508203754_AddWorkspaceDamBlobObjectNames.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddWorkspaceDamBlobObjectNames : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BlobName",
|
||||
table: "Assets",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlobName",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
2605
backend/src/Socialize.Api/Migrations/20260509170908_AddChannelImages.Designer.cs
generated
Normal file
2605
backend/src/Socialize.Api/Migrations/20260509170908_AddChannelImages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddChannelImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BannerUrl",
|
||||
table: "Channels",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PortraitUrl",
|
||||
table: "Channels",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BannerUrl",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PortraitUrl",
|
||||
table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
2604
backend/src/Socialize.Api/Migrations/20260509174221_MakeContentCampaignOptional.Designer.cs
generated
Normal file
2604
backend/src/Socialize.Api/Migrations/20260509174221_MakeContentCampaignOptional.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class MakeContentCampaignOptional : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "CampaignId",
|
||||
table: "ContentItems",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "CampaignId",
|
||||
table: "ContentItems",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: Guid.Empty,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
namespace Socialize.Api.Modules.Assets.Data;
|
||||
|
||||
public class Asset
|
||||
internal class Asset
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public Guid? ContentItemId { get; set; }
|
||||
public required string AssetType { get; set; }
|
||||
public required string SourceType { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public string? GoogleDriveFileId { get; set; }
|
||||
public string? GoogleDriveLink { get; set; }
|
||||
public string? GoogleDriveWorkspaceFolderPath { get; set; }
|
||||
public string? BlobName { get; set; }
|
||||
public string? PreviewUrl { get; set; }
|
||||
public int CurrentRevisionNumber { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -15,12 +17,22 @@ public static class AssetModelConfiguration
|
||||
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
|
||||
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
|
||||
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
|
||||
asset.Property(x => x.GoogleDriveWorkspaceFolderPath).HasMaxLength(512);
|
||||
asset.Property(x => x.BlobName).HasMaxLength(512);
|
||||
asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
|
||||
asset.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.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.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AssetRevision>(revision =>
|
||||
@@ -35,6 +47,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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,10 +81,29 @@ public class CreateAssetRevisionHandler(
|
||||
|
||||
if (contentItem is not null)
|
||||
{
|
||||
await activityWriter.WriteAsync(
|
||||
new ContentItemActivityWriteModel(
|
||||
asset.WorkspaceId,
|
||||
contentItem.Id,
|
||||
"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,
|
||||
asset.ContentItemId,
|
||||
contentItem.Id,
|
||||
"asset.revision.created",
|
||||
"AssetRevision",
|
||||
revision.Id,
|
||||
|
||||
@@ -3,12 +3,16 @@ 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 Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||
|
||||
public record CreateGoogleDriveAssetRequest(
|
||||
internal record CreateGoogleDriveAssetRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
@@ -17,7 +21,7 @@ public record CreateGoogleDriveAssetRequest(
|
||||
string GoogleDriveLink,
|
||||
string? PreviewUrl);
|
||||
|
||||
public class CreateGoogleDriveAssetRequestValidator
|
||||
internal class CreateGoogleDriveAssetRequestValidator
|
||||
: Validator<CreateGoogleDriveAssetRequest>
|
||||
{
|
||||
public CreateGoogleDriveAssetRequestValidator()
|
||||
@@ -32,9 +36,10 @@ public class CreateGoogleDriveAssetRequestValidator
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateGoogleDriveAssetHandler(
|
||||
internal class CreateGoogleDriveAssetHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
|
||||
{
|
||||
@@ -64,6 +69,26 @@ public class CreateGoogleDriveAssetHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string? workspaceFolderPath = organization.IsGoogleDriveDamEnabled
|
||||
? $"{organization.GoogleDriveRootFolderName}/{workspace.Slug}"
|
||||
: null;
|
||||
|
||||
Asset asset = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -74,6 +99,7 @@ public class CreateGoogleDriveAssetHandler(
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
|
||||
GoogleDriveLink = request.GoogleDriveLink.Trim(),
|
||||
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
|
||||
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
@@ -93,6 +119,26 @@ public class CreateGoogleDriveAssetHandler(
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await activityWriter.WriteAsync(
|
||||
new ContentItemActivityWriteModel(
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId.Value,
|
||||
"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,
|
||||
googleDriveWorkspaceFolderPath = asset.GoogleDriveWorkspaceFolderPath,
|
||||
currentRevisionNumber = asset.CurrentRevisionNumber,
|
||||
})),
|
||||
ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
@@ -115,6 +161,7 @@ public class CreateGoogleDriveAssetHandler(
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.GoogleDriveWorkspaceFolderPath,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
|
||||
@@ -2,12 +2,13 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||
|
||||
public record GetAssetsRequest(Guid ContentItemId);
|
||||
internal record GetAssetsRequest(Guid? ContentItemId, Guid? WorkspaceId);
|
||||
|
||||
public record AssetRevisionDto(
|
||||
internal record AssetRevisionDto(
|
||||
Guid Id,
|
||||
Guid AssetId,
|
||||
int RevisionNumber,
|
||||
@@ -17,21 +18,22 @@ public record AssetRevisionDto(
|
||||
Guid? CreatedByUserId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record AssetDto(
|
||||
internal record AssetDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? ContentItemId,
|
||||
string AssetType,
|
||||
string SourceType,
|
||||
string DisplayName,
|
||||
string? GoogleDriveFileId,
|
||||
string? GoogleDriveLink,
|
||||
string? GoogleDriveWorkspaceFolderPath,
|
||||
string? PreviewUrl,
|
||||
int CurrentRevisionNumber,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyCollection<AssetRevisionDto> Revisions);
|
||||
|
||||
public class GetAssetsHandler(
|
||||
internal class GetAssetsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
|
||||
@@ -43,9 +45,13 @@ public class GetAssetsHandler(
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Asset> query = dbContext.Assets;
|
||||
|
||||
if (request.ContentItemId.HasValue)
|
||||
{
|
||||
var item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
@@ -58,9 +64,28 @@ public class GetAssetsHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await dbContext.Assets
|
||||
.Where(asset => asset.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(asset => asset.DisplayName)
|
||||
query = query.Where(asset => asset.ContentItemId == request.ContentItemId.Value);
|
||||
}
|
||||
else if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
if (!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
query = query.Where(asset => asset.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(request => request.WorkspaceId, "A workspace or content item is required.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await query
|
||||
.OrderByDescending(asset => asset.CreatedAt)
|
||||
.ThenBy(asset => asset.DisplayName)
|
||||
.Select(asset => new AssetDto(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
@@ -70,6 +95,7 @@ public class GetAssetsHandler(
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.GoogleDriveWorkspaceFolderPath,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Assets.Services;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||
|
||||
internal record WorkspaceDamBackingStoreDto(
|
||||
string Type,
|
||||
bool IsConfigured,
|
||||
string? RootFolderId,
|
||||
string? RootFolderName,
|
||||
string? RootFolderUrl);
|
||||
|
||||
internal record WorkspaceDamFolderDto(
|
||||
string Name,
|
||||
string Path,
|
||||
string? ProviderFolderId);
|
||||
|
||||
internal record WorkspaceDamProviderItemDto(
|
||||
string Id,
|
||||
string Provider,
|
||||
string Name,
|
||||
string MimeType,
|
||||
bool IsFolder,
|
||||
string? OpenUrl,
|
||||
string? DownloadUrl,
|
||||
long? Size,
|
||||
DateTimeOffset? ModifiedTime);
|
||||
|
||||
internal record WorkspaceDamDto(
|
||||
Guid WorkspaceId,
|
||||
Guid OrganizationId,
|
||||
string WorkspaceName,
|
||||
string WorkspaceSlug,
|
||||
WorkspaceDamBackingStoreDto BackingStore,
|
||||
WorkspaceDamFolderDto? Folder,
|
||||
IReadOnlyCollection<WorkspaceDamProviderItemDto> ProviderItems,
|
||||
IReadOnlyCollection<AssetDto> Assets);
|
||||
|
||||
internal class GetWorkspaceDamHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService)
|
||||
: EndpointWithoutRequest<WorkspaceDamDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/workspaces/{workspaceId:guid}/dam");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||
if (!accessibleWorkspaceIds.Contains(workspace.Id))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool useGoogleDrive = organization.IsGoogleDriveDamEnabled;
|
||||
WorkspaceDamBackingStoreDto backingStore = new(
|
||||
useGoogleDrive ? "GoogleDrive" : "BlobStorage",
|
||||
true,
|
||||
organization.GoogleDriveRootFolderId,
|
||||
organization.GoogleDriveRootFolderName,
|
||||
organization.GoogleDriveRootFolderUrl);
|
||||
|
||||
WorkspaceDamFolderDto folder = new(
|
||||
"medias",
|
||||
useGoogleDrive
|
||||
? $"{organization.GoogleDriveRootFolderName}/{workspace.Slug}/medias"
|
||||
: $"blob://workspaces/{workspace.Id}/dam/medias",
|
||||
null);
|
||||
IReadOnlyCollection<WorkspaceDamProviderItemDto> providerItems = [];
|
||||
|
||||
if (useGoogleDrive)
|
||||
{
|
||||
GoogleDriveDamFolderContext folderContext = await googleDriveDamService
|
||||
.EnsureWorkspaceMediasFolderAsync(organization, workspace, ct);
|
||||
folder = new WorkspaceDamFolderDto(
|
||||
"medias",
|
||||
folderContext.Path,
|
||||
folderContext.MediasFolderId);
|
||||
IReadOnlyCollection<GoogleDriveDamItemDto> driveItems = await googleDriveDamService
|
||||
.ListChildrenAsync(
|
||||
organization,
|
||||
folderContext.MediasFolderId,
|
||||
ct);
|
||||
providerItems = driveItems
|
||||
.Select(item => new WorkspaceDamProviderItemDto(
|
||||
item.Id,
|
||||
"GoogleDrive",
|
||||
item.Name,
|
||||
item.MimeType,
|
||||
item.IsFolder,
|
||||
item.WebViewLink,
|
||||
item.WebContentLink,
|
||||
item.Size,
|
||||
item.ModifiedTime))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await dbContext.Assets
|
||||
.Where(asset => asset.WorkspaceId == workspace.Id)
|
||||
.OrderBy(asset => asset.DisplayName)
|
||||
.Select(asset => new AssetDto(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.GoogleDriveWorkspaceFolderPath,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
dbContext.AssetRevisions
|
||||
.Where(revision => revision.AssetId == asset.Id)
|
||||
.OrderByDescending(revision => revision.RevisionNumber)
|
||||
.Select(revision => new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!useGoogleDrive)
|
||||
{
|
||||
providerItems = assets
|
||||
.Where(asset => asset.SourceType == "BlobStorage")
|
||||
.Select(asset => new WorkspaceDamProviderItemDto(
|
||||
asset.Id.ToString(),
|
||||
"BlobStorage",
|
||||
asset.DisplayName,
|
||||
"application/octet-stream",
|
||||
false,
|
||||
asset.PreviewUrl,
|
||||
asset.PreviewUrl,
|
||||
null,
|
||||
asset.CreatedAt))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
new WorkspaceDamDto(
|
||||
workspace.Id,
|
||||
workspace.OrganizationId,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
backingStore,
|
||||
folder,
|
||||
providerItems,
|
||||
assets),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
using Socialize.Api.Modules.Assets.Services;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||
|
||||
internal record CreateWorkspaceDamFolderRequest(string Name, string? ParentFolderId);
|
||||
|
||||
internal record UploadWorkspaceDamFileRequest(IFormFile File, string? ParentFolderId);
|
||||
|
||||
internal record RenameWorkspaceDamItemRequest(string Name);
|
||||
|
||||
internal record WorkspaceDamOperationDto(
|
||||
string Provider,
|
||||
WorkspaceDamProviderItemDto? ProviderItem,
|
||||
AssetDto? Asset);
|
||||
|
||||
internal abstract class WorkspaceDamEndpointBase<TRequest, TResponse>
|
||||
: Endpoint<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
protected WorkspaceDamEndpointBase(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
{
|
||||
DbContext = dbContext;
|
||||
AccessScopeService = accessScopeService;
|
||||
}
|
||||
|
||||
protected AppDbContext DbContext { get; }
|
||||
|
||||
private AccessScopeService AccessScopeService { get; }
|
||||
|
||||
protected async Task<(Workspace? Workspace, Organization? Organization)> GetAuthorizedContextAsync(
|
||||
Guid workspaceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Workspace? workspace = await DbContext.Workspaces
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await AccessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||
if (!accessibleWorkspaceIds.Contains(workspace.Id))
|
||||
{
|
||||
return (workspace, null);
|
||||
}
|
||||
|
||||
Organization? organization = await DbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
|
||||
return (workspace, organization);
|
||||
}
|
||||
|
||||
protected async Task<(Workspace? Workspace, Organization? Organization, bool IsForbidden)> GetAuthorizedOrSendAsync(
|
||||
Guid workspaceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
(Workspace? workspace, Organization? organization) = await GetAuthorizedContextAsync(workspaceId, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return (null, null, false);
|
||||
}
|
||||
|
||||
if (organization is null)
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return (workspace, null, true);
|
||||
}
|
||||
|
||||
return (workspace, organization, false);
|
||||
}
|
||||
|
||||
protected static AssetDto ToAssetDto(Asset asset)
|
||||
{
|
||||
return new AssetDto(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.GoogleDriveWorkspaceFolderPath,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
[]);
|
||||
}
|
||||
|
||||
protected static WorkspaceDamProviderItemDto ToProviderItem(GoogleDriveDamItemDto item)
|
||||
{
|
||||
return new WorkspaceDamProviderItemDto(
|
||||
item.Id,
|
||||
"GoogleDrive",
|
||||
item.Name,
|
||||
item.MimeType,
|
||||
item.IsFolder,
|
||||
item.WebViewLink,
|
||||
item.WebContentLink,
|
||||
item.Size,
|
||||
item.ModifiedTime);
|
||||
}
|
||||
|
||||
protected static WorkspaceDamProviderItemDto ToProviderItem(Asset asset)
|
||||
{
|
||||
return new WorkspaceDamProviderItemDto(
|
||||
asset.Id.ToString(),
|
||||
"BlobStorage",
|
||||
asset.DisplayName,
|
||||
"application/octet-stream",
|
||||
false,
|
||||
asset.PreviewUrl,
|
||||
asset.PreviewUrl,
|
||||
null,
|
||||
asset.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CreateWorkspaceDamFolderHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService)
|
||||
: WorkspaceDamEndpointBase<CreateWorkspaceDamFolderRequest, WorkspaceDamProviderItemDto>(dbContext, accessScopeService)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/workspaces/{workspaceId:guid}/dam/folders");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateWorkspaceDamFolderRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
(Workspace? workspace, Organization? organization, bool isForbidden) = await GetAuthorizedOrSendAsync(workspaceId, ct);
|
||||
if (workspace is null || organization is null || isForbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organization.IsGoogleDriveDamEnabled)
|
||||
{
|
||||
AddError("Folder creation is currently available for Google Drive backed DAM workspaces.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamFolderContext context = await googleDriveDamService.EnsureWorkspaceMediasFolderAsync(
|
||||
organization,
|
||||
workspace,
|
||||
ct);
|
||||
string parentFolderId = string.IsNullOrWhiteSpace(request.ParentFolderId)
|
||||
? context.MediasFolderId
|
||||
: request.ParentFolderId.Trim();
|
||||
if (!await googleDriveDamService.IsWithinFolderAsync(organization, parentFolderId, context.MediasFolderId, ct))
|
||||
{
|
||||
AddError("The target Google Drive folder must be inside this workspace medias folder.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamItemDto folder = await googleDriveDamService.CreateFolderAsync(
|
||||
organization,
|
||||
parentFolderId,
|
||||
request.Name.Trim(),
|
||||
ct);
|
||||
|
||||
await SendAsync(ToProviderItem(folder), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed class UploadWorkspaceDamFileHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService,
|
||||
IBlobStorage blobStorage)
|
||||
: WorkspaceDamEndpointBase<UploadWorkspaceDamFileRequest, WorkspaceDamOperationDto>(dbContext, accessScopeService)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/workspaces/{workspaceId:guid}/dam/files");
|
||||
AllowFileUploads();
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UploadWorkspaceDamFileRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
(Workspace? workspace, Organization? organization, bool isForbidden) = await GetAuthorizedOrSendAsync(workspaceId, ct);
|
||||
if (workspace is null || organization is null || isForbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorkspaceDamOperationDto? result = organization.IsGoogleDriveDamEnabled
|
||||
? await UploadGoogleDriveAssetAsync(request, organization, workspace, ct)
|
||||
: await UploadBlobAssetAsync(request, workspace, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SendAsync(result, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private async Task<WorkspaceDamOperationDto?> UploadGoogleDriveAssetAsync(
|
||||
UploadWorkspaceDamFileRequest request,
|
||||
Organization organization,
|
||||
Workspace workspace,
|
||||
CancellationToken ct)
|
||||
{
|
||||
GoogleDriveDamFolderContext context = await googleDriveDamService.EnsureWorkspaceMediasFolderAsync(
|
||||
organization,
|
||||
workspace,
|
||||
ct);
|
||||
string parentFolderId = string.IsNullOrWhiteSpace(request.ParentFolderId)
|
||||
? context.MediasFolderId
|
||||
: request.ParentFolderId.Trim();
|
||||
if (!await googleDriveDamService.IsWithinFolderAsync(organization, parentFolderId, context.MediasFolderId, ct))
|
||||
{
|
||||
AddError("The target Google Drive folder must be inside this workspace medias folder.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
GoogleDriveDamItemDto item = await googleDriveDamService.UploadFileAsync(
|
||||
organization,
|
||||
parentFolderId,
|
||||
request.File,
|
||||
ct);
|
||||
|
||||
Asset asset = CreateAsset(
|
||||
workspace.Id,
|
||||
"GoogleDrive",
|
||||
request.File.FileName,
|
||||
item.Id,
|
||||
item.WebViewLink,
|
||||
context.Path);
|
||||
DbContext.Assets.Add(asset);
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
|
||||
return new WorkspaceDamOperationDto("GoogleDrive", ToProviderItem(item), ToAssetDto(asset));
|
||||
}
|
||||
|
||||
private async Task<WorkspaceDamOperationDto> UploadBlobAssetAsync(
|
||||
UploadWorkspaceDamFileRequest request,
|
||||
Workspace workspace,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string fileName = Path.GetFileName(request.File.FileName).Trim();
|
||||
string blobName = $"{workspace.Id}/dam/medias/{Guid.NewGuid():N}-{fileName}";
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Workspaces,
|
||||
blobName,
|
||||
request.File.OpenReadStream(),
|
||||
string.IsNullOrWhiteSpace(request.File.ContentType) ? "application/octet-stream" : request.File.ContentType,
|
||||
ct);
|
||||
|
||||
Asset asset = CreateAsset(
|
||||
workspace.Id,
|
||||
"BlobStorage",
|
||||
fileName,
|
||||
null,
|
||||
null,
|
||||
$"blob://workspaces/{workspace.Id}/dam/medias");
|
||||
asset.PreviewUrl = blobUrl;
|
||||
asset.BlobName = blobName;
|
||||
DbContext.Assets.Add(asset);
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
|
||||
return new WorkspaceDamOperationDto("BlobStorage", ToProviderItem(asset), ToAssetDto(asset));
|
||||
}
|
||||
|
||||
private static Asset CreateAsset(
|
||||
Guid workspaceId,
|
||||
string sourceType,
|
||||
string displayName,
|
||||
string? googleDriveFileId,
|
||||
string? googleDriveLink,
|
||||
string? workspaceFolderPath)
|
||||
{
|
||||
return new Asset
|
||||
{
|
||||
WorkspaceId = workspaceId,
|
||||
ContentItemId = null,
|
||||
AssetType = "Media",
|
||||
SourceType = sourceType,
|
||||
DisplayName = displayName,
|
||||
GoogleDriveFileId = googleDriveFileId,
|
||||
GoogleDriveLink = googleDriveLink,
|
||||
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
|
||||
CurrentRevisionNumber = 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RenameWorkspaceDamItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService)
|
||||
: WorkspaceDamEndpointBase<RenameWorkspaceDamItemRequest, WorkspaceDamOperationDto>(dbContext, accessScopeService)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Patch("/api/workspaces/{workspaceId:guid}/dam/items/{itemId}");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RenameWorkspaceDamItemRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
string itemId = Route<string>("itemId") ?? string.Empty;
|
||||
(Workspace? workspace, Organization? organization, bool isForbidden) = await GetAuthorizedOrSendAsync(workspaceId, ct);
|
||||
if (workspace is null || organization is null || isForbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.IsGoogleDriveDamEnabled)
|
||||
{
|
||||
GoogleDriveDamFolderContext context = await googleDriveDamService.EnsureWorkspaceMediasFolderAsync(organization, workspace, ct);
|
||||
if (!await googleDriveDamService.IsWithinFolderAsync(organization, itemId, context.MediasFolderId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamItemDto item = await googleDriveDamService.RenameFileAsync(organization, itemId, request.Name.Trim(), ct);
|
||||
Asset? asset = await DbContext.Assets.SingleOrDefaultAsync(
|
||||
candidate => candidate.WorkspaceId == workspace.Id && candidate.GoogleDriveFileId == itemId,
|
||||
ct);
|
||||
if (asset is not null)
|
||||
{
|
||||
asset.DisplayName = request.Name.Trim();
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendOkAsync(new WorkspaceDamOperationDto("GoogleDrive", ToProviderItem(item), asset is null ? null : ToAssetDto(asset)), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(itemId, out Guid assetId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset? blobAsset = await DbContext.Assets.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == assetId &&
|
||||
candidate.WorkspaceId == workspace.Id &&
|
||||
candidate.SourceType == "BlobStorage",
|
||||
ct);
|
||||
if (blobAsset is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
blobAsset.DisplayName = request.Name.Trim();
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
await SendOkAsync(new WorkspaceDamOperationDto("BlobStorage", ToProviderItem(blobAsset), ToAssetDto(blobAsset)), ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DeleteWorkspaceDamItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService,
|
||||
IBlobStorage blobStorage)
|
||||
: WorkspaceDamEndpointBase<EmptyRequest, EmptyResponse>(dbContext, accessScopeService)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/workspaces/{workspaceId:guid}/dam/items/{itemId}");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(EmptyRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
string itemId = Route<string>("itemId") ?? string.Empty;
|
||||
(Workspace? workspace, Organization? organization, bool isForbidden) = await GetAuthorizedOrSendAsync(workspaceId, ct);
|
||||
if (workspace is null || organization is null || isForbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.IsGoogleDriveDamEnabled)
|
||||
{
|
||||
GoogleDriveDamFolderContext context = await googleDriveDamService.EnsureWorkspaceMediasFolderAsync(organization, workspace, ct);
|
||||
if (!await googleDriveDamService.IsWithinFolderAsync(organization, itemId, context.MediasFolderId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await googleDriveDamService.DeleteFileAsync(organization, itemId, ct);
|
||||
Asset? asset = await DbContext.Assets.SingleOrDefaultAsync(
|
||||
candidate => candidate.WorkspaceId == workspace.Id && candidate.GoogleDriveFileId == itemId,
|
||||
ct);
|
||||
if (asset is not null)
|
||||
{
|
||||
DbContext.Assets.Remove(asset);
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(itemId, out Guid assetId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset? blobAsset = await DbContext.Assets.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == assetId &&
|
||||
candidate.WorkspaceId == workspace.Id &&
|
||||
candidate.SourceType == "BlobStorage",
|
||||
ct);
|
||||
if (blobAsset is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(blobAsset.BlobName))
|
||||
{
|
||||
await blobStorage.DeleteFileAsync(ContainerNames.Workspaces, blobAsset.BlobName, ct);
|
||||
}
|
||||
|
||||
DbContext.Assets.Remove(blobAsset);
|
||||
await DbContext.SaveChangesAsync(ct);
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DownloadWorkspaceDamItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
GoogleDriveDamService googleDriveDamService,
|
||||
IBlobStorage blobStorage)
|
||||
: WorkspaceDamEndpointBase<EmptyRequest, EmptyResponse>(dbContext, accessScopeService)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/workspaces/{workspaceId:guid}/dam/items/{itemId}/download");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(EmptyRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid workspaceId = Route<Guid>("workspaceId");
|
||||
string itemId = Route<string>("itemId") ?? string.Empty;
|
||||
(Workspace? workspace, Organization? organization, bool isForbidden) = await GetAuthorizedOrSendAsync(workspaceId, ct);
|
||||
if (workspace is null || organization is null || isForbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.IsGoogleDriveDamEnabled)
|
||||
{
|
||||
GoogleDriveDamFolderContext context = await googleDriveDamService.EnsureWorkspaceMediasFolderAsync(organization, workspace, ct);
|
||||
if (!await googleDriveDamService.IsWithinFolderAsync(organization, itemId, context.MediasFolderId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamDownload download = await googleDriveDamService.DownloadFileAsync(organization, itemId, ct);
|
||||
await SendStreamAsync(
|
||||
download.Stream,
|
||||
fileName: download.FileName ?? itemId,
|
||||
fileLengthBytes: download.Size,
|
||||
contentType: download.ContentType,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(itemId, out Guid assetId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset? blobAsset = await DbContext.Assets.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == assetId &&
|
||||
candidate.WorkspaceId == workspace.Id &&
|
||||
candidate.SourceType == "BlobStorage",
|
||||
ct);
|
||||
if (blobAsset?.BlobName is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
MemoryStream stream = await blobStorage.DownloadFileAsync(ContainerNames.Workspaces, blobAsset.BlobName, ct);
|
||||
await SendStreamAsync(
|
||||
stream,
|
||||
fileName: blobAsset.DisplayName,
|
||||
fileLengthBytes: stream.Length,
|
||||
contentType: "application/octet-stream",
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
267
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal file
267
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
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;
|
||||
|
||||
internal record UploadAssetRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
string? DisplayName,
|
||||
IFormFile File);
|
||||
|
||||
internal class UploadAssetRequestValidator
|
||||
: Validator<UploadAssetRequest>
|
||||
{
|
||||
public UploadAssetRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.DisplayName).MaximumLength(256);
|
||||
RuleFor(x => x.File).NotNull();
|
||||
}
|
||||
}
|
||||
|
||||
internal class UploadAssetHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
IBlobStorage blobStorage,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<UploadAssetRequest, AssetDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/assets/upload");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UploadAssetRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.File.Length <= 0)
|
||||
{
|
||||
AddError(request => request.File, "The media file must not be empty.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedContentType = request.File.ContentType.Trim();
|
||||
if (!IsSupportedMediaContentType(normalizedContentType))
|
||||
{
|
||||
AddError(request => request.File, "The media file must be an image or video.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid assetId = Guid.NewGuid();
|
||||
string fileName = NormalizeFileName(request.File.FileName, normalizedContentType);
|
||||
string displayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||
? Path.GetFileNameWithoutExtension(fileName)
|
||||
: request.DisplayName.Trim();
|
||||
string blobName =
|
||||
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/assets/{assetId}/{fileName}";
|
||||
string blobUrl;
|
||||
|
||||
try
|
||||
{
|
||||
blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Workspaces,
|
||||
blobName,
|
||||
request.File.OpenReadStream(),
|
||||
normalizedContentType,
|
||||
ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
AddError(request => request.File, "The media file is invalid or unsupported.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset asset = new()
|
||||
{
|
||||
Id = assetId,
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
AssetType = NormalizeAssetType(request.AssetType, normalizedContentType),
|
||||
SourceType = "Uploaded",
|
||||
DisplayName = displayName,
|
||||
GoogleDriveFileId = null,
|
||||
GoogleDriveLink = null,
|
||||
PreviewUrl = blobUrl,
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
AssetRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = asset.Id,
|
||||
RevisionNumber = 1,
|
||||
SourceReference = blobName,
|
||||
PreviewUrl = blobUrl,
|
||||
CreatedByUserId = User.GetUserId(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Assets.Add(asset);
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await activityWriter.WriteAsync(
|
||||
new ContentItemActivityWriteModel(
|
||||
asset.WorkspaceId,
|
||||
contentItem.Id,
|
||||
"asset.uploaded",
|
||||
"Asset",
|
||||
asset.Id,
|
||||
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
assetType = asset.AssetType,
|
||||
sourceType = asset.SourceType,
|
||||
currentRevisionNumber = asset.CurrentRevisionNumber,
|
||||
})),
|
||||
ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
contentItem.Id,
|
||||
"asset.uploaded",
|
||||
"Asset",
|
||||
asset.Id,
|
||||
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"assetId":"{{asset.Id}}"}"""),
|
||||
ct);
|
||||
|
||||
AssetDto dto = new(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.GoogleDriveWorkspaceFolderPath,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
[
|
||||
new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt)
|
||||
]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private static bool IsSupportedMediaContentType(string contentType)
|
||||
{
|
||||
string normalized = contentType.Trim();
|
||||
return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("image/gif", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("image/webp", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("video/webm", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeAssetType(string assetType, string contentType)
|
||||
{
|
||||
string normalized = assetType.Trim();
|
||||
if (!normalized.Equals("None", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalized.Length > 64 ? normalized[..64] : normalized;
|
||||
}
|
||||
|
||||
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ? "Video" : "Image";
|
||||
}
|
||||
|
||||
private static string NormalizeFileName(string? fileName, string contentType)
|
||||
{
|
||||
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
normalized = $"asset{DefaultExtension(contentType)}";
|
||||
}
|
||||
|
||||
return normalized.Length > 256 ? normalized[..256] : normalized;
|
||||
}
|
||||
|
||||
private static string DefaultExtension(string contentType)
|
||||
{
|
||||
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".png";
|
||||
}
|
||||
|
||||
if (contentType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".gif";
|
||||
}
|
||||
|
||||
if (contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".webp";
|
||||
}
|
||||
|
||||
if (contentType.Equals("video/webm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".webm";
|
||||
}
|
||||
|
||||
if (contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mov";
|
||||
}
|
||||
|
||||
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
return ".jpg";
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
using Socialize.Api.Modules.Assets.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets;
|
||||
|
||||
public static class DependencyInjection
|
||||
internal static class ModuleRegistration
|
||||
{
|
||||
public static WebApplicationBuilder AddAssetsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHttpClient<GoogleDriveDamService>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Services;
|
||||
|
||||
internal record GoogleDriveDamItemDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string MimeType,
|
||||
bool IsFolder,
|
||||
string? WebViewLink,
|
||||
string? WebContentLink,
|
||||
long? Size,
|
||||
DateTimeOffset? ModifiedTime);
|
||||
|
||||
internal record GoogleDriveDamDownload(
|
||||
Stream Stream,
|
||||
string? FileName,
|
||||
string ContentType,
|
||||
long? Size);
|
||||
|
||||
internal record GoogleDriveDamFolderContext(
|
||||
string WorkspaceFolderId,
|
||||
string MediasFolderId,
|
||||
string Path);
|
||||
|
||||
internal sealed class GoogleDriveDamService(HttpClient httpClient)
|
||||
{
|
||||
private const string FolderMimeType = "application/vnd.google-apps.folder";
|
||||
private static readonly Uri DriveFilesUri = new("https://www.googleapis.com/drive/v3/files");
|
||||
private static readonly Uri DriveFilesBaseUri = new("https://www.googleapis.com/drive/v3/files/");
|
||||
private static readonly Uri DriveUploadFilesUri = new("https://www.googleapis.com/upload/drive/v3/files");
|
||||
private static readonly Uri OAuthTokenUri = new("https://oauth2.googleapis.com/token");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<GoogleDriveDamFolderContext> EnsureWorkspaceMediasFolderAsync(
|
||||
Organization organization,
|
||||
Workspace workspace,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
string workspaceFolderId = await EnsureFolderAsync(
|
||||
accessToken,
|
||||
organization.GoogleDriveRootFolderId ?? throw new InvalidOperationException("Google Drive root folder is not configured."),
|
||||
workspace.Slug,
|
||||
ct);
|
||||
string mediasFolderId = await EnsureFolderAsync(accessToken, workspaceFolderId, "medias", ct);
|
||||
|
||||
return new GoogleDriveDamFolderContext(
|
||||
workspaceFolderId,
|
||||
mediasFolderId,
|
||||
$"{organization.GoogleDriveRootFolderName}/{workspace.Slug}/medias");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<GoogleDriveDamItemDto>> ListChildrenAsync(
|
||||
Organization organization,
|
||||
string folderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
string query = Uri.EscapeDataString($"'{folderId}' in parents and trashed = false");
|
||||
const string fields =
|
||||
"files(id,name,mimeType,webViewLink,webContentLink,size,modifiedTime)";
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Get,
|
||||
new Uri(DriveFilesUri, $"?q={query}&fields={Uri.EscapeDataString(fields)}&orderBy=folder,name"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
if (!document.RootElement.TryGetProperty("files", out JsonElement filesElement))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<GoogleDriveDamItemDto> items = [];
|
||||
foreach (JsonElement fileElement in filesElement.EnumerateArray())
|
||||
{
|
||||
string id = fileElement.GetProperty("id").GetString() ?? string.Empty;
|
||||
string name = fileElement.GetProperty("name").GetString() ?? string.Empty;
|
||||
string mimeType = fileElement.GetProperty("mimeType").GetString() ?? string.Empty;
|
||||
long? size = fileElement.TryGetProperty("size", out JsonElement sizeElement) &&
|
||||
long.TryParse(sizeElement.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsedSize)
|
||||
? parsedSize
|
||||
: null;
|
||||
DateTimeOffset? modifiedTime = fileElement.TryGetProperty("modifiedTime", out JsonElement modifiedElement) &&
|
||||
DateTimeOffset.TryParse(modifiedElement.GetString(), CultureInfo.InvariantCulture, out DateTimeOffset parsedModified)
|
||||
? parsedModified
|
||||
: null;
|
||||
|
||||
items.Add(new GoogleDriveDamItemDto(
|
||||
id,
|
||||
name,
|
||||
mimeType,
|
||||
string.Equals(mimeType, FolderMimeType, StringComparison.Ordinal),
|
||||
fileElement.TryGetProperty("webViewLink", out JsonElement webViewLinkElement) ? webViewLinkElement.GetString() : null,
|
||||
fileElement.TryGetProperty("webContentLink", out JsonElement webContentLinkElement) ? webContentLinkElement.GetString() : null,
|
||||
size,
|
||||
modifiedTime));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<GoogleDriveDamItemDto> CreateFolderAsync(
|
||||
Organization organization,
|
||||
string parentFolderId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
return await CreateFolderCoreAsync(accessToken, parentFolderId, name, ct);
|
||||
}
|
||||
|
||||
public async Task<GoogleDriveDamItemDto> UploadFileAsync(
|
||||
Organization organization,
|
||||
string parentFolderId,
|
||||
IFormFile file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
using MultipartContent multipart = new("related");
|
||||
string metadata = JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
name = file.FileName,
|
||||
parents = new[] { parentFolderId },
|
||||
},
|
||||
JsonOptions);
|
||||
#pragma warning disable CA2000 // MultipartContent owns and disposes nested content after the request completes.
|
||||
multipart.Add(new StringContent(metadata, Encoding.UTF8, "application/json"));
|
||||
StreamContent fileContent = new(file.OpenReadStream());
|
||||
#pragma warning restore CA2000
|
||||
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||
multipart.Add(fileContent);
|
||||
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Post,
|
||||
new Uri(
|
||||
DriveUploadFilesUri,
|
||||
"?uploadType=multipart&fields=id,name,mimeType,webViewLink,webContentLink,size,modifiedTime"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = multipart;
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
return ParseItem(document.RootElement);
|
||||
}
|
||||
|
||||
public async Task<GoogleDriveDamItemDto> RenameFileAsync(
|
||||
Organization organization,
|
||||
string fileId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
string metadata = JsonSerializer.Serialize(new { name }, JsonOptions);
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Patch,
|
||||
new Uri(
|
||||
DriveFilesBaseUri,
|
||||
$"{Uri.EscapeDataString(fileId)}?fields=id,name,mimeType,webViewLink,webContentLink,size,modifiedTime"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = new StringContent(metadata, Encoding.UTF8, "application/json");
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
return ParseItem(document.RootElement);
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(
|
||||
Organization organization,
|
||||
string fileId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Delete,
|
||||
new Uri(DriveFilesBaseUri, Uri.EscapeDataString(fileId)));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
}
|
||||
|
||||
public async Task<GoogleDriveDamDownload> DownloadFileAsync(
|
||||
Organization organization,
|
||||
string fileId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Get,
|
||||
new Uri(DriveFilesBaseUri, $"{Uri.EscapeDataString(fileId)}?alt=media"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync(ct);
|
||||
MemoryStream memoryStream = new();
|
||||
await responseStream.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return new GoogleDriveDamDownload(
|
||||
memoryStream,
|
||||
response.Content.Headers.ContentDisposition?.FileNameStar ??
|
||||
response.Content.Headers.ContentDisposition?.FileName?.Trim('"'),
|
||||
response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream",
|
||||
response.Content.Headers.ContentLength);
|
||||
}
|
||||
|
||||
public async Task<bool> IsWithinFolderAsync(
|
||||
Organization organization,
|
||||
string itemId,
|
||||
string ancestorFolderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string accessToken = await GetAccessTokenAsync(organization, ct);
|
||||
return await IsWithinFolderCoreAsync(accessToken, itemId, ancestorFolderId, ct);
|
||||
}
|
||||
|
||||
private async Task<bool> IsWithinFolderCoreAsync(
|
||||
string accessToken,
|
||||
string itemId,
|
||||
string ancestorFolderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(itemId, ancestorFolderId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Queue<string> pending = new([itemId]);
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
string currentId = pending.Dequeue();
|
||||
if (!visited.Add(currentId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Get,
|
||||
new Uri(DriveFilesBaseUri, $"{Uri.EscapeDataString(currentId)}?fields=parents"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
if (!document.RootElement.TryGetProperty("parents", out JsonElement parentsElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (JsonElement parentElement in parentsElement.EnumerateArray())
|
||||
{
|
||||
string? parentId = parentElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(parentId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(parentId, ancestorFolderId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
pending.Enqueue(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<string> EnsureFolderAsync(
|
||||
string accessToken,
|
||||
string parentFolderId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string escapedName = name.Replace("'", "\\'", StringComparison.Ordinal);
|
||||
string query = Uri.EscapeDataString(
|
||||
$"'{parentFolderId}' in parents and name = '{escapedName}' and mimeType = '{FolderMimeType}' and trashed = false");
|
||||
using HttpRequestMessage listRequest = new(
|
||||
HttpMethod.Get,
|
||||
new Uri(DriveFilesUri, $"?q={query}&fields=files(id,name,mimeType)&pageSize=1"));
|
||||
listRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using HttpResponseMessage listResponse = await httpClient.SendAsync(listRequest, ct);
|
||||
await ThrowIfDriveErrorAsync(listResponse, ct);
|
||||
await using Stream stream = await listResponse.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
JsonElement filesElement = document.RootElement.GetProperty("files");
|
||||
JsonElement.ArrayEnumerator files = filesElement.EnumerateArray();
|
||||
if (files.MoveNext())
|
||||
{
|
||||
return files.Current.GetProperty("id").GetString() ??
|
||||
throw new InvalidOperationException("Google Drive folder response did not include an id.");
|
||||
}
|
||||
|
||||
GoogleDriveDamItemDto createdFolder = await CreateFolderCoreAsync(accessToken, parentFolderId, name, ct);
|
||||
return createdFolder.Id;
|
||||
}
|
||||
|
||||
private async Task<GoogleDriveDamItemDto> CreateFolderCoreAsync(
|
||||
string accessToken,
|
||||
string parentFolderId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string metadata = JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
name,
|
||||
mimeType = FolderMimeType,
|
||||
parents = new[] { parentFolderId },
|
||||
},
|
||||
JsonOptions);
|
||||
using HttpRequestMessage request = new(
|
||||
HttpMethod.Post,
|
||||
new Uri(DriveFilesUri, "?fields=id,name,mimeType,webViewLink,webContentLink,size,modifiedTime"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = new StringContent(metadata, Encoding.UTF8, "application/json");
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
return ParseItem(document.RootElement);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(Organization organization, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(organization.GoogleDriveRefreshToken) &&
|
||||
!string.IsNullOrWhiteSpace(organization.GoogleDriveClientId) &&
|
||||
!string.IsNullOrWhiteSpace(organization.GoogleDriveClientSecret))
|
||||
{
|
||||
using FormUrlEncodedContent content = new(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = organization.GoogleDriveClientId,
|
||||
["client_secret"] = organization.GoogleDriveClientSecret,
|
||||
["refresh_token"] = organization.GoogleDriveRefreshToken,
|
||||
["grant_type"] = "refresh_token",
|
||||
});
|
||||
using HttpResponseMessage response = await httpClient.PostAsync(OAuthTokenUri, content, ct);
|
||||
await ThrowIfDriveErrorAsync(response, ct);
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
return document.RootElement.GetProperty("access_token").GetString() ??
|
||||
throw new InvalidOperationException("Google OAuth response did not include an access token.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(organization.GoogleDriveAccessToken))
|
||||
{
|
||||
return organization.GoogleDriveAccessToken;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Google Drive credentials are not configured.");
|
||||
}
|
||||
|
||||
private static GoogleDriveDamItemDto ParseItem(JsonElement fileElement)
|
||||
{
|
||||
string mimeType = fileElement.GetProperty("mimeType").GetString() ?? string.Empty;
|
||||
return new GoogleDriveDamItemDto(
|
||||
fileElement.GetProperty("id").GetString() ?? string.Empty,
|
||||
fileElement.GetProperty("name").GetString() ?? string.Empty,
|
||||
mimeType,
|
||||
string.Equals(mimeType, FolderMimeType, StringComparison.Ordinal),
|
||||
fileElement.TryGetProperty("webViewLink", out JsonElement webViewLinkElement) ? webViewLinkElement.GetString() : null,
|
||||
fileElement.TryGetProperty("webContentLink", out JsonElement webContentLinkElement) ? webContentLinkElement.GetString() : null,
|
||||
fileElement.TryGetProperty("size", out JsonElement sizeElement) &&
|
||||
long.TryParse(sizeElement.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsedSize)
|
||||
? parsedSize
|
||||
: null,
|
||||
fileElement.TryGetProperty("modifiedTime", out JsonElement modifiedElement) &&
|
||||
DateTimeOffset.TryParse(modifiedElement.GetString(), CultureInfo.InvariantCulture, out DateTimeOffset parsedModified)
|
||||
? parsedModified
|
||||
: null);
|
||||
}
|
||||
|
||||
private static async Task ThrowIfDriveErrorAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException(
|
||||
$"Google Drive request failed with {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user