45 Commits

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

View File

@@ -24,7 +24,7 @@ jobs:
-t git.mapachotes.com/jbourdon/socialize-api:latest \ -t git.mapachotes.com/jbourdon/socialize-api:latest \
-f backend/src/Socialize.Api/Dockerfile . -f backend/src/Socialize.Api/Dockerfile .
docker build \ docker build \
--build-arg VITE_API_URL=/api \ --build-arg VITE_API_URL=/ \
-t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \ -t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \
-t git.mapachotes.com/jbourdon/socialize-web:latest \ -t git.mapachotes.com/jbourdon/socialize-web:latest \
-f frontend/Dockerfile . -f frontend/Dockerfile .
@@ -37,8 +37,9 @@ jobs:
deploy: deploy:
needs: image needs: image
runs-on: bookworm runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- name: Install SSH client - name: Install SSH client
run: apt-get update && apt-get install -y openssh-client run: apt-get update && apt-get install -y openssh-client
- name: Deploy on sobina - name: Deploy on sobina
@@ -46,9 +47,29 @@ jobs:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }} DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }}
SOCIALIZE_IMAGE_TAG: ${{ gitea.sha }}
run: | run: |
: "${SOCIALIZE_IMAGE_TAG:?SOCIALIZE_IMAGE_TAG is required}"
mkdir -p ~/.ssh mkdir -p ~/.ssh
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.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" \ ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
'cd /srv/prod/socialize && ./deploy.sh' 'test -r /etc/socialize/socialize.env && cd /srv/prod/socialize && ./deploy.sh'

13
.gitignore vendored
View File

@@ -22,6 +22,10 @@ Thumbs.db
# .NET # .NET
bin/ bin/
obj/ obj/
**/[Bb]in/
**/[Oo]bj/
**/[Bb]in[\\]*
**/[Oo]bj[\\]*
TestResults/ TestResults/
# Node # Node
@@ -30,6 +34,7 @@ dist/
.vite/ .vite/
# Local environment files # Local environment files
.env
*.local *.local
.env.local .env.local
.env.*.local .env.*.local
@@ -38,5 +43,11 @@ App_Data/
# Local SSL certificates # Local SSL certificates
*.pem *.pem
# Ai # AI agent local state
.agents
.agents/
.codex .codex
.codex/
# Generated local artifacts
.artifacts/

View File

@@ -76,6 +76,12 @@ http://localhost:8080
http://<this-machine-lan-ip>:8080 http://<this-machine-lan-ip>:8080
``` ```
For preprod deployment, configure the `POSTGRES_PASSWORD`, `RESEND_API_KEY`,
`RESEND_FROM_EMAIL`, and `JWT_SIGNING_KEY` Gitea secrets.
The deploy workflow writes the remote `.env` file and syncs `deploy/compose.yml`
before running the server deploy script.
Use the raw Resend API key value for `RESEND_API_KEY`, without a `Bearer ` prefix.
## Solution ## Solution
```bash ```bash
@@ -90,6 +96,24 @@ cd frontend
npm run build npm run build
``` ```
## Database Diagram
Start PostgreSQL, then generate a local schema diagram:
```bash
./scripts/generate-db-diagram.sh
```
The script writes an HTML viewer, SVG, PNG, and Graphviz source under:
```txt
.artifacts/db-diagrams/
```
Use `DATABASE_URL`, `PGPASSWORD`, or `~/.pgpass` to provide local database credentials.
When using the repository infrastructure script, the diagram script can read from the
running `socialize-postgres` container directly.
## Agentic Workflow ## Agentic Workflow
Start here: Start here:

View File

@@ -11,7 +11,7 @@ using Microsoft.IdentityModel.Tokens;
namespace Socialize; namespace Socialize;
public static class DependencyInjection internal static class ApplicationRegistration
{ {
public static IServiceCollection AddWebServices(this IServiceCollection services) public static IServiceCollection AddWebServices(this IServiceCollection services)
{ {
@@ -70,7 +70,6 @@ public static class DependencyInjection
{ {
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
{ {
jwtBearerOptions.Authority = "https://hutopy.com";
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
@@ -79,7 +78,7 @@ public static class DependencyInjection
ValidAudience = authJwt["Audience"], ValidAudience = authJwt["Audience"],
ValidateLifetime = true, ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ?? IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
throw new ArgumentNullException("The Jwt Key is missing."))) throw new InvalidOperationException("Authentication:Jwt:Key is required.")))
}; };
}); });
} }
@@ -90,9 +89,9 @@ public static class DependencyInjection
authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options => authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authGoogle["ClientId"] ?? options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing."); throw new InvalidOperationException("Authentication:Google:ClientId is required.");
options.ClientSecret = authGoogle["ClientSecret"] ?? options.ClientSecret = authGoogle["ClientSecret"] ??
throw new ArgumentNullException("The Google ClientSecret is missing."); throw new InvalidOperationException("Authentication:Google:ClientSecret is required.");
}); });
} }
@@ -102,9 +101,9 @@ public static class DependencyInjection
authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options => authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authFacebook["ClientId"] ?? options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientId is required.");
options.ClientSecret = authFacebook["ClientSecret"] ?? options.ClientSecret = authFacebook["ClientSecret"] ??
throw new ArgumentNullException("The Facebook ClientSecret is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientSecret is required.");
}); });
} }

View File

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

View File

@@ -12,15 +12,19 @@ using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data; using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data; namespace Socialize.Api.Data;
public class AppDbContext( internal class AppDbContext(
DbContextOptions<AppDbContext> options) DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options) : IdentityDbContext<User, Role, Guid>(options)
{ {
public DbSet<Organization> Organizations => Set<Organization>(); public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
public DbSet<OrganizationMembershipTierTranslation> OrganizationMembershipTierTranslations =>
Set<OrganizationMembershipTierTranslation>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>(); public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>(); public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>(); public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
@@ -47,6 +51,10 @@ public class AppDbContext(
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>(); public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>(); public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>(); public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
public DbSet<ReleaseUpdate> ReleaseUpdates => Set<ReleaseUpdate>();
public DbSet<ReleaseUpdateReadReceipt> ReleaseUpdateReadReceipts => Set<ReleaseUpdateReadReceipt>();
public DbSet<ReleaseCommit> ReleaseCommits => Set<ReleaseCommit>();
public DbSet<ReleaseUpdateEmailDigestReceipt> ReleaseUpdateEmailDigestReceipts => Set<ReleaseUpdateEmailDigestReceipt>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -64,5 +72,6 @@ public class AppDbContext(
builder.ConfigureNotificationsModule(); builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule(); builder.ConfigureFeedbackModule();
builder.ConfigureCalendarIntegrationsModule(); builder.ConfigureCalendarIntegrationsModule();
builder.ConfigureReleaseCommunicationsModule();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ using Socialize.Api.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Services; namespace Socialize.Api.Infrastructure.BlobStorage.Services;
public sealed class LocalBlobStorage( internal sealed class LocalBlobStorage(
IWebHostEnvironment environment, IWebHostEnvironment environment,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IOptions<LocalBlobStorageOptions> options, IOptions<LocalBlobStorageOptions> options,
@@ -14,6 +14,14 @@ public sealed class LocalBlobStorage(
private const long MaxUploadSize = 10 * 1024 * 1024; private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type"; private const string ContentTypeMetadataSuffix = ".content-type";
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
private static readonly Action<ILogger, string, string, string, string, Exception?> LogUploadedFile =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(UploadFileAsync)),
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]");
private readonly LocalBlobStorageOptions _options = options.Value; private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> UploadFileAsync( public async Task<string> UploadFileAsync(
@@ -46,12 +54,7 @@ public sealed class LocalBlobStorage(
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct); await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath); string fileUri = BuildPublicUrl(relativePath);
logger.LogInformation( LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
blobName,
containerName,
contentType,
fileUri);
return fileUri; return fileUri;
} }
@@ -106,7 +109,7 @@ public sealed class LocalBlobStorage(
throw new InvalidOperationException("Blob storage: Blob paths must be relative."); throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
} }
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])]; string[] pathParts = [containerName, .. blobName.Split(PathSeparators)];
if (pathParts.Any(part => part is "" or "." or "..")) if (pathParts.Any(part => part is "" or "." or ".."))
{ {
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments."); throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
@@ -135,7 +138,7 @@ public sealed class LocalBlobStorage(
? "/api/storage" ? "/api/storage"
: requestPath.Trim(); : requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) return normalized.StartsWith('/')
? normalized.TrimEnd('/') ? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}"; : $"/{normalized.TrimEnd('/')}";
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,19 @@ using Socialize.Api.Infrastructure.Emailer.Contracts;
namespace Socialize.Api.Infrastructure.Emailer.Services; namespace Socialize.Api.Infrastructure.Emailer.Services;
public class LoggerEmailSender(ILogger<IEmailSender> logger) internal class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender : IEmailSender
{ {
public async Task SendEmailAsync(string email, string subject, string message) private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(SendEmailAsync)),
"Development email to {Email} with subject {Subject}:{NewLine}{Message}");
public Task SendEmailAsync(string email, string subject, string message)
{ {
try LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
{
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject); return Task.CompletedTask;
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;
}
} }
} }

View File

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

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace Socialize.Api.Infrastructure.Emailer.Services; namespace Socialize.Api.Infrastructure.Emailer.Services;
public class ResendEmailSender : IEmailSender internal class ResendEmailSender : IEmailSender
{ {
private static readonly Uri EndpointUri = new("https://api.resend.com/emails"); private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -20,21 +20,36 @@ public class ResendEmailSender : IEmailSender
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_options = options.Value; _options = options.Value;
string apiKey = NormalizeApiKey(_options.ApiKey);
string fromEmail = _options.FromEmail?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("Emailer:ApiKey is required when using Resend email delivery.");
}
if (string.IsNullOrWhiteSpace(fromEmail))
{
throw new InvalidOperationException("Emailer:FromEmail is required when using Resend email delivery.");
}
_options.ApiKey = apiKey;
_options.FromEmail = fromEmail;
_httpClient.DefaultRequestHeaders.Authorization = _httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _options.ApiKey); new AuthenticationHeaderValue("Bearer", apiKey);
_httpClient.DefaultRequestHeaders.Accept.Add( _httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json")); new MediaTypeWithQualityHeaderValue("application/json"));
} }
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage) public async Task SendEmailAsync(string email, string subject, string message)
{ {
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage }; var payload = new { from = _options.FromEmail, to = email, subject, html = message };
string json = JsonSerializer.Serialize(payload); string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json"); using StringContent content = new(json, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -43,4 +58,16 @@ public class ResendEmailSender : IEmailSender
$"Resend email failed: {response.StatusCode} - {body}"); $"Resend email failed: {response.StatusCode} - {body}");
} }
} }
private static string NormalizeApiKey(string? apiKey)
{
string normalized = apiKey?.Trim().Trim('"', '\'') ?? string.Empty;
const string bearerPrefix = "Bearer ";
if (normalized.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[bearerPrefix.Length..].Trim();
}
return normalized;
}
} }

View File

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

View File

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

View File

@@ -4,52 +4,52 @@ using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService( internal sealed class AccessScopeService(
OrganizationAccessService organizationAccessService) OrganizationAccessService organizationAccessService)
{ {
public bool IsManager(ClaimsPrincipal user) public static bool IsManager(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
} }
public bool IsProvider(ClaimsPrincipal user) public static bool IsProvider(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Provider); return user.IsInRole(KnownRoles.Provider);
} }
public bool IsClient(ClaimsPrincipal user) public static bool IsClient(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Client); return user.IsInRole(KnownRoles.Client);
} }
public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId) public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
{ {
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId); return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
} }
public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
{ {
return IsManager(user) && CanAccessWorkspace(user, workspaceId); return IsManager(user) && CanAccessWorkspace(user, workspaceId);
} }
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId) public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{ {
return IsManager(user) return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); || (CanAccessWorkspace(user, workspaceId) && 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) return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId)); || (CanAccessClient(user, workspaceId, clientId) && 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) || (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)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId) || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,23 @@
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
public class MissingClaimException( public class MissingClaimException : Exception
string claimName) {
: Exception($"Claim '{claimName}' is missing."); public MissingClaimException()
{
}
public MissingClaimException(string message)
: base(message)
{
}
public MissingClaimException(string message, Exception innerException)
: base(message, innerException)
{
}
internal static MissingClaimException ForClaim(string claimName)
{
return new MissingClaimException($"Claim '{claimName}' is missing.");
}
}

View File

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

View File

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

View File

@@ -15,12 +15,14 @@ using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services; using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Api.Infrastructure.Development; namespace Socialize.Api.Infrastructure.TestData;
public static class DevelopmentSeedExtensions #pragma warning disable S1075 // Test data intentionally uses representative external URLs.
internal static class TestDataSeedExtensions
{ {
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
@@ -39,23 +41,11 @@ public static class DevelopmentSeedExtensions
private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777"); private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777");
private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888"); private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888");
public static async Task<IApplicationBuilder> UseDevelopmentSeedAsync( public static async Task<IServiceProvider> SeedTestDataAsync(
this IApplicationBuilder app, this IServiceProvider services,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
IHostEnvironment environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>(); using IServiceScope scope = services.CreateScope();
if (!environment.IsDevelopment())
{
return app;
}
using IServiceScope scope = app.ApplicationServices.CreateScope();
IOptions<DevelopmentSeedOptions> options = scope.ServiceProvider.GetRequiredService<IOptions<DevelopmentSeedOptions>>();
if (!options.Value.Enabled)
{
return app;
}
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>(); UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -64,7 +54,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
username: "manager", username: "manager",
email: "manager@socialize.local", email: "manager@socialize.local",
password: "manager", password: "Manager1!",
alias: "Northstar Manager", alias: "Northstar Manager",
firstname: "Morgan", firstname: "Morgan",
lastname: "Reid", lastname: "Reid",
@@ -80,7 +70,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
username: "client", username: "client",
email: "client@socialize.local", email: "client@socialize.local",
password: "client", password: "Client1!",
alias: "Sofia Martin", alias: "Sofia Martin",
firstname: "Sofia", firstname: "Sofia",
lastname: "Martin", lastname: "Martin",
@@ -97,7 +87,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
username: "provider", username: "provider",
email: "provider@socialize.local", email: "provider@socialize.local",
password: "provider", password: "Provider1!",
alias: "Alex Studio", alias: "Alex Studio",
firstname: "Alex", firstname: "Alex",
lastname: "Studio", lastname: "Studio",
@@ -115,7 +105,7 @@ public static class DevelopmentSeedExtensions
id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
username: "dev", username: "dev",
email: "dev@socialize.local", email: "dev@socialize.local",
password: "dev", password: "Developer1!",
alias: "Socialize Dev", alias: "Socialize Dev",
firstname: "Jo", firstname: "Jo",
lastname: "Bumble", lastname: "Bumble",
@@ -138,7 +128,7 @@ public static class DevelopmentSeedExtensions
dbContext, dbContext,
cancellationToken); cancellationToken);
return app; return services;
} }
private static async Task<User> EnsureUserAsync( private static async Task<User> EnsureUserAsync(
@@ -175,7 +165,7 @@ public static class DevelopmentSeedExtensions
if (!createResult.Succeeded) if (!createResult.Succeeded)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}"); $"Failed to seed test user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
} }
} }
@@ -195,7 +185,7 @@ public static class DevelopmentSeedExtensions
if (!passwordResetResult.Succeeded) if (!passwordResetResult.Succeeded)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}"); $"Failed to set test password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
} }
} }
@@ -222,13 +212,7 @@ public static class DevelopmentSeedExtensions
await userManager.RemoveClaimAsync(user, claim); await userManager.RemoveClaimAsync(user, claim);
} }
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) string persona = GetPersona(roles);
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)])) foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{ {
@@ -238,6 +222,26 @@ public static class DevelopmentSeedExtensions
return user; return user;
} }
private static string GetPersona(IReadOnlyCollection<string> roles)
{
if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal))
{
return KnownRoles.Manager;
}
if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal))
{
return KnownRoles.Client;
}
if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal))
{
return KnownRoles.Provider;
}
return KnownRoles.WorkspaceMember;
}
private static async Task EnsureOrganizationDataAsync( private static async Task EnsureOrganizationDataAsync(
Guid managerUserId, Guid managerUserId,
Guid developerUserId, Guid developerUserId,
@@ -258,6 +262,11 @@ public static class DevelopmentSeedExtensions
} }
organization.Name = "Northstar Agency"; 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; organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync( await UpsertOrganizationMembershipAsync(
@@ -461,6 +470,7 @@ public static class DevelopmentSeedExtensions
asset.DisplayName = "Spring launch cut"; asset.DisplayName = "Spring launch cut";
asset.GoogleDriveFileId = "dev-socialize-demo"; asset.GoogleDriveFileId = "dev-socialize-demo";
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view"; 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.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
asset.CurrentRevisionNumber = 2; asset.CurrentRevisionNumber = 2;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -583,6 +593,7 @@ public static class DevelopmentSeedExtensions
{ {
Id = id, Id = id,
Name = string.Empty, Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty, TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -590,6 +601,12 @@ public static class DevelopmentSeedExtensions
} }
workspace.Name = name; workspace.Name = name;
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
organizationId,
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
workspace.Id,
cancellationToken);
workspace.OrganizationId = organizationId; workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId; workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone; workspace.TimeZone = timeZone;

View File

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

View File

@@ -12,7 +12,7 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations namespace Socialize.Api.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260505192305_Initial")] [Migration("20260507143849_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -912,6 +912,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AttachmentBlobContainerName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentBlobName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("AttachmentBlobUrl")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("AttachmentContentType")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentFileName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long?>("AttachmentSizeBytes")
.HasColumnType("bigint");
b.Property<string>("AuthorDisplayName") b.Property<string>("AuthorDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)

View File

@@ -5,11 +5,12 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional #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 namespace Socialize.Api.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class Initial : Migration internal partial class Initial : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -282,6 +283,12 @@ namespace Socialize.Api.Migrations
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false), Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
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") CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
}, },
constraints: table => constraints: table =>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
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 "CreatedAt", "Id") AS "SlugIndex"
FROM normalized
)
UPDATE "Workspaces"
SET "Slug" = left(
CASE
WHEN numbered."SlugIndex" = 1 THEN numbered."BaseSlug"
ELSE numbered."BaseSlug" || '-' || 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");
}
}
}

View File

@@ -374,6 +374,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl") b.Property<string>("PreviewUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -909,6 +913,29 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AttachmentBlobContainerName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentBlobName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("AttachmentBlobUrl")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("AttachmentContentType")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AttachmentFileName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long?>("AttachmentSizeBytes")
.HasColumnType("bigint");
b.Property<string>("AuthorDisplayName") b.Property<string>("AuthorDisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -1336,6 +1363,12 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("ClientId");
b.HasIndex("ContentItemId");
b.HasIndex("LastActivityAt"); b.HasIndex("LastActivityAt");
b.HasIndex("ReporterUserId"); b.HasIndex("ReporterUserId");
@@ -1493,6 +1526,9 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastAuthenticatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Lastname") b.Property<string>("Lastname")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@@ -1626,10 +1662,32 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<Guid>("MembershipTierId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001"));
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -1640,6 +1698,8 @@ namespace Socialize.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MembershipTierId");
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Organizations", (string)null); b.ToTable("Organizations", (string)null);
@@ -1679,6 +1739,407 @@ namespace Socialize.Api.Migrations
b.ToTable("OrganizationMemberships", (string)null); b.ToTable("OrganizationMemberships", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int?>("ActiveContentLimit")
.HasColumnType("integer");
b.Property<int?>("ExternalReviewerLimit")
.HasColumnType("integer");
b.Property<bool>("IsCustom")
.HasColumnType("boolean");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("MemberLimit")
.HasColumnType("integer");
b.Property<int?>("MonthlyPriceCents")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int?>("WorkspaceLimit")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.HasIndex("SortOrder");
b.ToTable("OrganizationMembershipTiers", (string)null);
b.HasData(
new
{
Id = new Guid("20000000-0000-0000-0000-000000000001"),
ActiveContentLimit = 3,
ExternalReviewerLimit = 1,
IsCustom = false,
Key = "free",
MemberLimit = 2,
MonthlyPriceCents = 0,
SortOrder = 10,
WorkspaceLimit = 1
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000002"),
ActiveContentLimit = 25,
ExternalReviewerLimit = 10,
IsCustom = false,
Key = "freelance",
MemberLimit = 5,
MonthlyPriceCents = 1900,
SortOrder = 20,
WorkspaceLimit = 3
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000003"),
ActiveContentLimit = 250,
IsCustom = false,
Key = "agency",
MemberLimit = 25,
MonthlyPriceCents = 7900,
SortOrder = 30,
WorkspaceLimit = 15
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000004"),
IsCustom = true,
Key = "enterprise",
SortOrder = 40
});
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Culture")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("MembershipTierId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("MembershipTierId", "Culture")
.IsUnique();
b.ToTable("OrganizationMembershipTierTranslations", (string)null);
b.HasData(
new
{
Id = new Guid("20000000-0000-0001-0000-000000000001"),
Culture = "en",
Description = "For trying Socialize on one real approval workflow.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"),
Name = "Free"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000002"),
Culture = "fr",
Description = "Pour essayer Socialize sur un vrai workflow d'approbation.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"),
Name = "Free"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000003"),
Culture = "en",
Description = "For solo operators managing recurring client reviews.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"),
Name = "Freelance"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000004"),
Culture = "fr",
Description = "Pour les independants qui gerent des revisions client recurrentes.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"),
Name = "Freelance"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000005"),
Culture = "en",
Description = "For agencies that need repeatable client approval operations.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"),
Name = "Agency"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000006"),
Culture = "fr",
Description = "Pour les agences qui veulent des operations d'approbation client repetables.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"),
Name = "Agency"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000007"),
Culture = "en",
Description = "For larger organizations with governance and access needs.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"),
Name = "Enterprise"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000008"),
Culture = "fr",
Description = "Pour les grandes organisations avec des besoins de gouvernance et d'acces.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"),
Name = "Enterprise"
});
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
{
b.Property<string>("Sha")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("AuthoredAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("CommittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CommunicationStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("ImportedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("ReleaseUpdateId")
.HasColumnType("uuid");
b.Property<string>("ShortSha")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("SourceBranch")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Sha");
b.HasIndex("CommittedAt");
b.HasIndex("CommunicationStatus");
b.HasIndex("ReleaseUpdateId");
b.ToTable("ReleaseCommits", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ArchivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Audience")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Body")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<string>("BuildVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("CommitRange")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ManualEmailAudience")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ManualEmailSentAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ManualEmailSentByUserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Audience");
b.HasIndex("CreatedByUserId");
b.HasIndex("PublishedAt");
b.HasIndex("Status");
b.ToTable("ReleaseUpdates", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateEmailDigestReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("UpdateCount")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SentAt");
b.HasIndex("UserId");
b.ToTable("ReleaseUpdateEmailDigestReceipts", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("ReadAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("ReleaseUpdateId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("ReleaseUpdateId", "UserId")
.IsUnique();
b.ToTable("ReleaseUpdateReadReceipts", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1727,6 +2188,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -1738,6 +2204,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });
@@ -1833,6 +2302,83 @@ namespace Socialize.Api.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b =>
{ {
b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null) b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null)
@@ -1842,6 +2388,104 @@ namespace Socialize.Api.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{ {
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1864,6 +2508,29 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport"); 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 => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
{ {
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1886,6 +2553,29 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport"); 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.Organization", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null)
.WithMany()
.HasForeignKey("MembershipTierId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{ {
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
@@ -1895,6 +2585,36 @@ namespace Socialize.Api.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null)
.WithMany()
.HasForeignKey("MembershipTierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
{
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
.WithMany()
.HasForeignKey("ReleaseUpdateId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ReleaseUpdate");
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
{
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
.WithMany("ReadReceipts")
.HasForeignKey("ReleaseUpdateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReleaseUpdate");
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
@@ -1904,6 +2624,15 @@ namespace Socialize.Api.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{ {
b.Navigation("ActivityEntries"); b.Navigation("ActivityEntries");
@@ -1914,6 +2643,11 @@ namespace Socialize.Api.Migrations
b.Navigation("Tags"); b.Navigation("Tags");
}); });
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
{
b.Navigation("ReadReceipts");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

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

View File

@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Data; namespace Socialize.Api.Modules.Approvals.Data;
public static class ApprovalModelConfiguration internal static class ApprovalModelConfiguration
{ {
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
{ {
@@ -20,6 +22,14 @@ public static class ApprovalModelConfiguration
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State }) workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
.IsUnique() .IsUnique()
.HasFilter("\"State\" = 'Pending'"); .HasFilter("\"State\" = 'Pending'");
workflowInstance.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
workflowInstance.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<ApprovalRequest>(approvalRequest => modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
@@ -40,6 +50,18 @@ public static class ApprovalModelConfiguration
approvalRequest.HasIndex(x => x.ContentItemId); approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId); approvalRequest.HasIndex(x => x.WorkflowInstanceId);
approvalRequest.HasIndex(x => x.ReviewerEmail); approvalRequest.HasIndex(x => x.ReviewerEmail);
approvalRequest.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
approvalRequest.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
approvalRequest.HasOne<ApprovalWorkflowInstance>()
.WithMany()
.HasForeignKey(x => x.WorkflowInstanceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<ApprovalDecision>(approvalDecision => modelBuilder.Entity<ApprovalDecision>(approvalDecision =>
@@ -54,6 +76,10 @@ public static class ApprovalModelConfiguration
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalDecision.HasIndex(x => x.ApprovalRequestId); approvalDecision.HasIndex(x => x.ApprovalRequestId);
approvalDecision.HasOne<ApprovalRequest>()
.WithMany()
.HasForeignKey(x => x.ApprovalRequestId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep => modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
@@ -69,6 +95,10 @@ public static class ApprovalModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalStep.HasIndex(x => x.WorkspaceId); approvalStep.HasIndex(x => x.WorkspaceId);
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique(); approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
approvalStep.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
return modelBuilder; return modelBuilder;

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,16 +8,17 @@ using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest( internal record SubmitApprovalDecisionRequest(
string Decision, string Decision,
string? ReviewerName, string? ReviewerName,
string? ReviewerEmail); string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator internal class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest> : Validator<SubmitApprovalDecisionRequest>
{ {
public SubmitApprovalDecisionRequestValidator() public SubmitApprovalDecisionRequestValidator()
@@ -31,7 +32,7 @@ public class SubmitApprovalDecisionRequestValidator
} }
} }
public class SubmitApprovalDecisionHandler( internal class SubmitApprovalDecisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService, ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
@@ -79,12 +80,14 @@ public class SubmitApprovalDecisionHandler(
} }
string normalizedDecision = request.Decision.Trim(); string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true ClaimsPrincipal? currentUser = User;
? User.GetAlias() ?? User.GetName() bool isAuthenticated = currentUser?.Identity?.IsAuthenticated == true;
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim(); string decidedByName = isAuthenticated
string decidedByEmail = User?.Identity?.IsAuthenticated == true ? currentUser!.GetAlias() ?? currentUser!.GetName()
? User.GetEmail() : GetReviewerName(request.ReviewerName, approval.ReviewerName);
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim(); string decidedByEmail = isAuthenticated
? currentUser!.GetEmail()
: GetReviewerEmail(request.ReviewerEmail, approval.ReviewerEmail);
ApprovalDecision decision = new() ApprovalDecision decision = new()
{ {
@@ -207,4 +210,18 @@ public class SubmitApprovalDecisionHandler(
await SendOkAsync(dto, ct); await SendOkAsync(dto, ct);
} }
private static string GetReviewerName(string? requestedName, string fallbackName)
{
return string.IsNullOrWhiteSpace(requestedName)
? fallbackName
: requestedName.Trim();
}
private static string GetReviewerEmail(string? requestedEmail, string fallbackEmail)
{
return string.IsNullOrWhiteSpace(requestedEmail)
? fallbackEmail
: requestedEmail.Trim();
}
} }

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,15 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Services; 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, bool Succeeded,
string? ErrorMessage, string? ErrorMessage,
int StatusCode, int StatusCode,
bool IsWorkflowStep); bool IsWorkflowStep);
public class ApprovalWorkflowRuntimeService( internal class ApprovalWorkflowRuntimeService(
AppDbContext dbContext, AppDbContext dbContext,
INotificationEventWriter notificationEventWriter) INotificationEventWriter notificationEventWriter)
{ {
@@ -145,13 +145,15 @@ public class ApprovalWorkflowRuntimeService(
dbContext.ApprovalDecisions.Add(decision); dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions var approvalDecisionParticipants = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState) .Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue .Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString() ? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail.ToLower()) : candidate.DecidedByEmail)
.Distinct() .ToListAsync(ct);
.CountAsync(ct); int approvedCount = approvalDecisionParticipants
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1; int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount)) if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
@@ -394,7 +396,7 @@ public class ApprovalWorkflowRuntimeService(
private static string CreateAccessToken() 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); private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public class Asset internal class Asset
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
@@ -10,6 +10,7 @@ public class Asset
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; } public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; } public string? GoogleDriveLink { get; set; }
public string? GoogleDriveWorkspaceFolderPath { get; set; }
public string? PreviewUrl { get; set; } public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; } public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }

View File

@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public static class AssetModelConfiguration internal static class AssetModelConfiguration
{ {
public static ModelBuilder ConfigureAssetsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureAssetsModule(this ModelBuilder modelBuilder)
{ {
@@ -15,12 +17,21 @@ public static class AssetModelConfiguration
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired(); asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256); asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048); asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
asset.Property(x => x.GoogleDriveWorkspaceFolderPath).HasMaxLength(512);
asset.Property(x => x.PreviewUrl).HasMaxLength(2048); asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
asset.Property(x => x.CreatedAt) asset.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
asset.HasIndex(x => x.WorkspaceId); asset.HasIndex(x => x.WorkspaceId);
asset.HasIndex(x => x.ContentItemId); asset.HasIndex(x => x.ContentItemId);
asset.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
asset.HasOne<ContentItem>()
.WithMany()
.HasForeignKey(x => x.ContentItemId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<AssetRevision>(revision => modelBuilder.Entity<AssetRevision>(revision =>
@@ -35,6 +46,10 @@ public static class AssetModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
revision.HasIndex(x => x.AssetId); revision.HasIndex(x => x.AssetId);
revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique(); revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique();
revision.HasOne<Asset>()
.WithMany()
.HasForeignKey(x => x.AssetId)
.OnDelete(DeleteBehavior.Cascade);
}); });
return modelBuilder; return modelBuilder;

View File

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

View File

@@ -10,12 +10,12 @@ using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateAssetRevisionRequest( internal record CreateAssetRevisionRequest(
string SourceReference, string SourceReference,
string? PreviewUrl, string? PreviewUrl,
string? Notes); string? Notes);
public class CreateAssetRevisionRequestValidator internal class CreateAssetRevisionRequestValidator
: Validator<CreateAssetRevisionRequest> : Validator<CreateAssetRevisionRequest>
{ {
public CreateAssetRevisionRequestValidator() public CreateAssetRevisionRequestValidator()
@@ -26,7 +26,7 @@ public class CreateAssetRevisionRequestValidator
} }
} }
public class CreateAssetRevisionHandler( internal class CreateAssetRevisionHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter, IContentItemActivityWriter activityWriter,

View File

@@ -6,11 +6,13 @@ using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateGoogleDriveAssetRequest( internal record CreateGoogleDriveAssetRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
string AssetType, string AssetType,
@@ -19,7 +21,7 @@ public record CreateGoogleDriveAssetRequest(
string GoogleDriveLink, string GoogleDriveLink,
string? PreviewUrl); string? PreviewUrl);
public class CreateGoogleDriveAssetRequestValidator internal class CreateGoogleDriveAssetRequestValidator
: Validator<CreateGoogleDriveAssetRequest> : Validator<CreateGoogleDriveAssetRequest>
{ {
public CreateGoogleDriveAssetRequestValidator() public CreateGoogleDriveAssetRequestValidator()
@@ -34,7 +36,7 @@ public class CreateGoogleDriveAssetRequestValidator
} }
} }
public class CreateGoogleDriveAssetHandler( internal class CreateGoogleDriveAssetHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
IContentItemActivityWriter activityWriter, IContentItemActivityWriter activityWriter,
@@ -67,6 +69,26 @@ public class CreateGoogleDriveAssetHandler(
return; 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() Asset asset = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -77,6 +99,7 @@ public class CreateGoogleDriveAssetHandler(
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(), GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(), GoogleDriveLink = request.GoogleDriveLink.Trim(),
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(), PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1, CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -111,6 +134,7 @@ public class CreateGoogleDriveAssetHandler(
assetType = asset.AssetType, assetType = asset.AssetType,
sourceType = asset.SourceType, sourceType = asset.SourceType,
googleDriveFileId = asset.GoogleDriveFileId, googleDriveFileId = asset.GoogleDriveFileId,
googleDriveWorkspaceFolderPath = asset.GoogleDriveWorkspaceFolderPath,
currentRevisionNumber = asset.CurrentRevisionNumber, currentRevisionNumber = asset.CurrentRevisionNumber,
})), })),
ct); ct);
@@ -137,6 +161,7 @@ public class CreateGoogleDriveAssetHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -5,9 +5,9 @@ using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
public record GetAssetsRequest(Guid ContentItemId); internal record GetAssetsRequest(Guid ContentItemId);
public record AssetRevisionDto( internal record AssetRevisionDto(
Guid Id, Guid Id,
Guid AssetId, Guid AssetId,
int RevisionNumber, int RevisionNumber,
@@ -17,7 +17,7 @@ public record AssetRevisionDto(
Guid? CreatedByUserId, Guid? CreatedByUserId,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
public record AssetDto( internal record AssetDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ContentItemId, Guid ContentItemId,
@@ -26,12 +26,13 @@ public record AssetDto(
string DisplayName, string DisplayName,
string? GoogleDriveFileId, string? GoogleDriveFileId,
string? GoogleDriveLink, string? GoogleDriveLink,
string? GoogleDriveWorkspaceFolderPath,
string? PreviewUrl, string? PreviewUrl,
int CurrentRevisionNumber, int CurrentRevisionNumber,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
IReadOnlyCollection<AssetRevisionDto> Revisions); IReadOnlyCollection<AssetRevisionDto> Revisions);
public class GetAssetsHandler( internal class GetAssetsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>> : Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
@@ -70,6 +71,7 @@ public class GetAssetsHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -0,0 +1,123 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
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);
internal record WorkspaceDamDto(
Guid WorkspaceId,
Guid OrganizationId,
string WorkspaceName,
string WorkspaceSlug,
WorkspaceDamBackingStoreDto BackingStore,
WorkspaceDamFolderDto? Folder,
IReadOnlyCollection<AssetDto> Assets);
internal class GetWorkspaceDamHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: 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;
}
WorkspaceDamBackingStoreDto backingStore = new(
organization.IsGoogleDriveDamEnabled ? "GoogleDrive" : "Unconfigured",
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
WorkspaceDamFolderDto? folder = organization.IsGoogleDriveDamEnabled
? new WorkspaceDamFolderDto(
workspace.Slug,
$"{organization.GoogleDriveRootFolderName}/{workspace.Slug}")
: null;
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);
await SendOkAsync(
new WorkspaceDamDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
backingStore,
folder,
assets),
ct);
}
}

View File

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

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarCatalogEntry internal class CalendarCatalogEntry
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Title { get; set; } public required string Title { get; set; }

View File

@@ -1,6 +1,8 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public static class CalendarCatalogSeed #pragma warning disable S1075 // Catalog seed entries intentionally store source URLs.
internal static class CalendarCatalogSeed
{ {
public static readonly CalendarCatalogEntry[] Entries = public static readonly CalendarCatalogEntry[] Entries =
[ [

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarEvent internal class CalendarEvent
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid CalendarSourceId { get; set; } public Guid CalendarSourceId { get; set; }

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class CalendarSource internal class CalendarSource
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Scope { get; set; } public required string Scope { get; set; }

View File

@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public static class CalendarSourceModelConfiguration internal static class CalendarSourceModelConfiguration
{ {
public static ModelBuilder ConfigureCalendarIntegrationsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureCalendarIntegrationsModule(this ModelBuilder modelBuilder)
{ {

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
public class UserCalendarExportFeed internal class UserCalendarExportFeed
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public Guid UserId { get; set; } public Guid UserId { get; set; }

View File

@@ -3,7 +3,7 @@ using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record CalendarSourceDto( internal record CalendarSourceDto(
Guid Id, Guid Id,
string Scope, string Scope,
Guid? OrganizationId, Guid? OrganizationId,
@@ -47,7 +47,7 @@ public record CalendarSourceDto(
} }
} }
public record UpsertCalendarSourceRequest( internal record UpsertCalendarSourceRequest(
string Scope, string Scope,
Guid? OrganizationId, Guid? OrganizationId,
Guid? WorkspaceId, Guid? WorkspaceId,
@@ -59,7 +59,7 @@ public record UpsertCalendarSourceRequest(
bool IsEnabled, bool IsEnabled,
string? InheritanceMode); string? InheritanceMode);
public class UpsertCalendarSourceRequestValidator internal class UpsertCalendarSourceRequestValidator
: FastEndpoints.Validator<UpsertCalendarSourceRequest> : FastEndpoints.Validator<UpsertCalendarSourceRequest>
{ {
public UpsertCalendarSourceRequestValidator() public UpsertCalendarSourceRequestValidator()

View File

@@ -8,7 +8,7 @@ using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class CreateCalendarSourceHandler( internal class CreateCalendarSourceHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService) OrganizationAccessService organizationAccessService)
@@ -121,7 +121,7 @@ public class CreateCalendarSourceHandler(
source.CatalogSourceReference == normalizedCatalogReference) || source.CatalogSourceReference == normalizedCatalogReference) ||
(!string.IsNullOrWhiteSpace(normalizedUrl) && (!string.IsNullOrWhiteSpace(normalizedUrl) &&
source.SourceUrl != null && source.SourceUrl != null &&
source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()), EF.Functions.ILike(source.SourceUrl, normalizedUrl)),
ct); ct);
} }

View File

@@ -8,7 +8,7 @@ using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class DeleteCalendarSourceHandler( internal class DeleteCalendarSourceHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService) OrganizationAccessService organizationAccessService)

View File

@@ -5,7 +5,7 @@ using Socialize.Api.Modules.CalendarIntegrations.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public sealed class ListCalendarCatalogRequest internal sealed class ListCalendarCatalogRequest
{ {
public string? Search { get; set; } public string? Search { get; set; }
public string? Country { get; set; } public string? Country { get; set; }
@@ -16,7 +16,7 @@ public sealed class ListCalendarCatalogRequest
public string? Provider { get; set; } public string? Provider { get; set; }
} }
public record CalendarCatalogEntryDto( internal record CalendarCatalogEntryDto(
Guid Id, Guid Id,
string Title, string Title,
string Description, string Description,
@@ -30,7 +30,7 @@ public record CalendarCatalogEntryDto(
string TrustLevel, string TrustLevel,
string DefaultColor); string DefaultColor);
public class ListCalendarCatalogHandler(AppDbContext dbContext) internal class ListCalendarCatalogHandler(AppDbContext dbContext)
: Endpoint<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>> : Endpoint<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>>
{ {
public override void Configure() public override void Configure()
@@ -47,11 +47,11 @@ public class ListCalendarCatalogHandler(AppDbContext dbContext)
if (!string.IsNullOrWhiteSpace(request.Search)) if (!string.IsNullOrWhiteSpace(request.Search))
{ {
string search = request.Search.Trim().ToLowerInvariant(); string search = $"%{request.Search.Trim()}%";
query = query.Where(entry => query = query.Where(entry =>
entry.Title.ToLower().Contains(search) || EF.Functions.ILike(entry.Title, search) ||
entry.Description.ToLower().Contains(search) || EF.Functions.ILike(entry.Description, search) ||
entry.ProviderName.ToLower().Contains(search)); EF.Functions.ILike(entry.ProviderName, search));
} }
if (!string.IsNullOrWhiteSpace(request.Country)) if (!string.IsNullOrWhiteSpace(request.Country))

View File

@@ -7,14 +7,14 @@ using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public sealed class ListCalendarEventsRequest internal sealed class ListCalendarEventsRequest
{ {
public Guid? WorkspaceId { get; set; } public Guid? WorkspaceId { get; set; }
public DateOnly? StartDate { get; set; } public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; } public DateOnly? EndDate { get; set; }
} }
public record CalendarEventDto( internal record CalendarEventDto(
Guid Id, Guid Id,
Guid CalendarSourceId, Guid CalendarSourceId,
string SourceEventUid, string SourceEventUid,
@@ -35,7 +35,7 @@ public record CalendarEventDto(
DateTimeOffset? SourceLastModifiedAt, DateTimeOffset? SourceLastModifiedAt,
DateTimeOffset ImportedAt); DateTimeOffset ImportedAt);
public class ListCalendarEventsHandler( internal class ListCalendarEventsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>> : Endpoint<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>>

View File

@@ -7,9 +7,9 @@ using Socialize.Api.Modules.CalendarIntegrations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record ListCalendarSourcesRequest(Guid? WorkspaceId); internal record ListCalendarSourcesRequest(Guid? WorkspaceId);
public class ListCalendarSourcesHandler( internal class ListCalendarSourcesHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>> : Endpoint<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>>

View File

@@ -8,7 +8,7 @@ using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class RefreshCalendarSourceHandler( internal class RefreshCalendarSourceHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService, OrganizationAccessService organizationAccessService,

View File

@@ -8,7 +8,7 @@ using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public class UpdateCalendarSourceHandler( internal class UpdateCalendarSourceHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService, AccessScopeService accessScopeService,
OrganizationAccessService organizationAccessService) OrganizationAccessService organizationAccessService)

View File

@@ -8,14 +8,14 @@ using Socialize.Api.Modules.Identity.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
public record UserCalendarExportFeedDto( internal record UserCalendarExportFeedDto(
bool IsEnabled, bool IsEnabled,
string? FeedUrl, string? FeedUrl,
DateTimeOffset? CreatedAt, DateTimeOffset? CreatedAt,
DateTimeOffset? UpdatedAt, DateTimeOffset? UpdatedAt,
DateTimeOffset? RevokedAt); DateTimeOffset? RevokedAt);
public class GetUserCalendarExportFeedHandler(AppDbContext dbContext) internal class GetUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto> : EndpointWithoutRequest<UserCalendarExportFeedDto>
{ {
public override void Configure() public override void Configure()
@@ -33,7 +33,7 @@ public class GetUserCalendarExportFeedHandler(AppDbContext dbContext)
} }
} }
public class EnableUserCalendarExportFeedHandler(AppDbContext dbContext) internal class EnableUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto> : EndpointWithoutRequest<UserCalendarExportFeedDto>
{ {
public override void Configure() public override void Configure()
@@ -81,7 +81,7 @@ public class EnableUserCalendarExportFeedHandler(AppDbContext dbContext)
} }
} }
public class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext) internal class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto> : EndpointWithoutRequest<UserCalendarExportFeedDto>
{ {
public override void Configure() public override void Configure()
@@ -120,7 +120,7 @@ public class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext)
} }
} }
public class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext) internal class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext)
: EndpointWithoutRequest<UserCalendarExportFeedDto> : EndpointWithoutRequest<UserCalendarExportFeedDto>
{ {
public override void Configure() public override void Configure()
@@ -147,7 +147,7 @@ public class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext)
} }
} }
public class GetUserCalendarExportFeedIcsHandler( internal class GetUserCalendarExportFeedIcsHandler(
AppDbContext dbContext, AppDbContext dbContext,
CalendarExportFeedService feedService) CalendarExportFeedService feedService)
: EndpointWithoutRequest : EndpointWithoutRequest

View File

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

View File

@@ -1,8 +1,9 @@
using System.Text; using System.Text;
using System.Globalization;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed record CalendarExportFeedEvent( internal sealed record CalendarExportFeedEvent(
string Uid, string Uid,
string Title, string Title,
DateTimeOffset StartsAt, DateTimeOffset StartsAt,
@@ -11,9 +12,9 @@ public sealed record CalendarExportFeedEvent(
string? Description, string? Description,
string? Url); string? Url);
public class CalendarExportFeedBuilder internal static class CalendarExportFeedBuilder
{ {
public string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events) public static string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events)
{ {
StringBuilder builder = new(); StringBuilder builder = new();
builder.AppendLine("BEGIN:VCALENDAR"); builder.AppendLine("BEGIN:VCALENDAR");
@@ -21,34 +22,34 @@ public class CalendarExportFeedBuilder
builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN"); builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN");
builder.AppendLine("CALSCALE:GREGORIAN"); builder.AppendLine("CALSCALE:GREGORIAN");
builder.AppendLine("METHOD:PUBLISH"); builder.AppendLine("METHOD:PUBLISH");
builder.AppendLine($"X-WR-CALNAME:{EscapeText(calendarName)}"); AppendLineInvariant(builder, $"X-WR-CALNAME:{EscapeText(calendarName)}");
foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt)) foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt))
{ {
builder.AppendLine("BEGIN:VEVENT"); builder.AppendLine("BEGIN:VEVENT");
builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}"); AppendLineInvariant(builder, $"UID:{EscapeText(feedEvent.Uid)}");
builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}"); AppendLineInvariant(builder, $"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}");
builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}"); AppendLineInvariant(builder, $"SUMMARY:{EscapeText(feedEvent.Title)}");
if (feedEvent.IsAllDay) if (feedEvent.IsAllDay)
{ {
builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}"); AppendLineInvariant(builder, $"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}"); AppendLineInvariant(builder, $"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}");
} }
else else
{ {
builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}"); AppendLineInvariant(builder, $"DTSTART:{FormatUtc(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}"); AppendLineInvariant(builder, $"DTEND:{FormatUtc(feedEvent.EndsAt)}");
} }
if (!string.IsNullOrWhiteSpace(feedEvent.Description)) if (!string.IsNullOrWhiteSpace(feedEvent.Description))
{ {
builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}"); AppendLineInvariant(builder, $"DESCRIPTION:{EscapeText(feedEvent.Description)}");
} }
if (!string.IsNullOrWhiteSpace(feedEvent.Url)) if (!string.IsNullOrWhiteSpace(feedEvent.Url))
{ {
builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}"); AppendLineInvariant(builder, $"URL:{EscapeText(feedEvent.Url)}");
} }
builder.AppendLine("END:VEVENT"); builder.AppendLine("END:VEVENT");
@@ -71,10 +72,15 @@ public class CalendarExportFeedBuilder
private static string EscapeText(string value) private static string EscapeText(string value)
{ {
return value return value
.Replace("\\", "\\\\") .Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\r\n", "\\n") .Replace("\r\n", "\\n", StringComparison.Ordinal)
.Replace("\n", "\\n") .Replace("\n", "\\n", StringComparison.Ordinal)
.Replace(";", "\\;") .Replace(";", "\\;", StringComparison.Ordinal)
.Replace(",", "\\,"); .Replace(",", "\\,", StringComparison.Ordinal);
}
private static void AppendLineInvariant(StringBuilder builder, FormattableString value)
{
builder.AppendLine(value.ToString(CultureInfo.InvariantCulture));
} }
} }

View File

@@ -3,11 +3,11 @@ using Socialize.Api.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder) internal class CalendarExportFeedService(AppDbContext dbContext)
{ {
public async Task<string> BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct) public async Task<string> BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct)
{ {
string normalizedEmail = userEmail?.Trim().ToUpperInvariant() ?? string.Empty; string normalizedEmail = userEmail?.Trim() ?? string.Empty;
Guid[] workspaceIds = await dbContext.Workspaces Guid[] workspaceIds = await dbContext.Workspaces
.Where(workspace => .Where(workspace =>
workspace.OwnerUserId == userId || workspace.OwnerUserId == userId ||
@@ -51,7 +51,7 @@ public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFee
.Where(approval => .Where(approval =>
approval.DueAt.HasValue && approval.DueAt.HasValue &&
(approval.RequestedByUserId == userId || (approval.RequestedByUserId == userId ||
(!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail))) (!string.IsNullOrEmpty(normalizedEmail) && EF.Functions.ILike(approval.ReviewerEmail, normalizedEmail))))
.Join( .Join(
dbContext.ContentItems, dbContext.ContentItems,
approval => approval.ContentItemId, approval => approval.ContentItemId,
@@ -91,7 +91,7 @@ public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFee
appBaseUrl)) appBaseUrl))
.ToListAsync(ct)); .ToListAsync(ct));
return feedBuilder.Build("Socialize my work", events); return CalendarExportFeedBuilder.Build("Socialize my work", events);
} }
private static CalendarExportFeedEvent ToContentFeedEvent( private static CalendarExportFeedEvent ToContentFeedEvent(

View File

@@ -2,7 +2,7 @@ using System.Security.Cryptography;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarExportFeedTokenService internal static class CalendarExportFeedTokenService
{ {
public static string GenerateToken() public static string GenerateToken()
{ {

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportBackgroundService( internal sealed class CalendarImportBackgroundService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<CalendarImportBackgroundService> logger) ILogger<CalendarImportBackgroundService> logger)
: BackgroundService : BackgroundService
@@ -23,12 +23,15 @@ public sealed class CalendarImportBackgroundService(
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>(); CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
await syncService.RefreshDueSourcesAsync(stoppingToken); await syncService.RefreshDueSourcesAsync(stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{ {
logger.LogDebug(ex, "Calendar import background sync stopped.");
} }
#pragma warning disable CA1031 // Background service should log and continue after unexpected sync failures.
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Calendar import background sync failed."); logger.LogError(ex, "Calendar import background sync failed.");
} }
#pragma warning restore CA1031
} }
} }

View File

@@ -1,15 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.CalendarIntegrations.Data;
using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportSyncService( #pragma warning disable S1075 // Supplemental observance identifiers intentionally use stable URI-like values.
internal sealed class CalendarImportSyncService(
AppDbContext dbContext, AppDbContext dbContext,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory)
IcsCalendarParser parser)
{ {
private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web);
public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct) public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct)
{ {
CalendarSource? source = await dbContext.CalendarSources CalendarSource? source = await dbContext.CalendarSources
@@ -115,7 +119,7 @@ public sealed class CalendarImportSyncService(
} }
} }
private async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync( private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync(
HttpClient httpClient, HttpClient httpClient,
string sourceUrl, string sourceUrl,
DateOnly rangeStart, DateOnly rangeStart,
@@ -127,8 +131,8 @@ public sealed class CalendarImportSyncService(
return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct); return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct);
} }
string content = await httpClient.GetStringAsync(sourceUrl, ct); string content = await httpClient.GetStringAsync(new Uri(sourceUrl), ct);
return parser.Parse(content, rangeStart, rangeEnd); return IcsCalendarParser.Parse(content, rangeStart, rangeEnd);
} }
private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync( private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync(
@@ -143,14 +147,12 @@ public sealed class CalendarImportSyncService(
for (int year = rangeStart.Year; year <= rangeEnd.Year; year++) for (int year = rangeStart.Year; year <= rangeEnd.Year; year++)
{ {
string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year); string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year);
string json = await httpClient.GetStringAsync(yearUrl, ct); string json = await httpClient.GetStringAsync(new Uri(yearUrl), ct);
NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>( NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>(json, JsonSerializerOptions) ?? [];
json,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
foreach (NagerHoliday holiday in holidays) foreach (NagerHoliday holiday in holidays)
{ {
if (!DateOnly.TryParse(holiday.Date, out DateOnly date) || if (!DateOnly.TryParse(holiday.Date, CultureInfo.InvariantCulture, out DateOnly date) ||
date < rangeStart || date < rangeStart ||
date > rangeEnd) date > rangeEnd)
{ {
@@ -283,7 +285,7 @@ public sealed class CalendarImportSyncService(
private static string NormalizeUidPart(string? value) private static string NormalizeUidPart(string? value)
{ {
return new string((value ?? "holiday") return new string((value ?? "holiday")
.ToLowerInvariant() .ToUpperInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-') .Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray()) .ToArray())
.Trim('-'); .Trim('-');

View File

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

View File

@@ -2,7 +2,7 @@ using Socialize.Api.Modules.CalendarIntegrations.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarSourceRules internal static class CalendarSourceRules
{ {
public static readonly string[] SupportedScopes = public static readonly string[] SupportedScopes =
[ [

View File

@@ -1,8 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Text;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public record ParsedCalendarEvent( internal record ParsedCalendarEvent(
string SourceEventUid, string SourceEventUid,
string Title, string Title,
string? Description, string? Description,
@@ -39,9 +40,9 @@ internal sealed record IcsRawEvent(
string? SourceUrl, string? SourceUrl,
DateTimeOffset? LastModifiedAt); DateTimeOffset? LastModifiedAt);
public sealed class IcsCalendarParser internal static class IcsCalendarParser
{ {
public IReadOnlyCollection<ParsedCalendarEvent> Parse( public static IReadOnlyCollection<ParsedCalendarEvent> Parse(
string content, string content,
DateOnly rangeStart, DateOnly rangeStart,
DateOnly rangeEnd) DateOnly rangeEnd)
@@ -63,10 +64,12 @@ public sealed class IcsCalendarParser
private static IEnumerable<IcsRawEvent> ReadRawEvents(string content) private static IEnumerable<IcsRawEvent> ReadRawEvents(string content)
{ {
List<string> lines = UnfoldLines(content).ToList(); List<string> lines = UnfoldLines(content).ToList();
for (int index = 0; index < lines.Count; index++) int index = 0;
while (index < lines.Count)
{ {
if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{ {
index++;
continue; continue;
} }
@@ -74,9 +77,10 @@ public sealed class IcsCalendarParser
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
index++; index++;
for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++) while (index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
{ {
ParseProperty(lines[index], properties); ParseProperty(lines[index], properties);
index++;
} }
if (!TryGetFirst(properties, "DTSTART", out var startProperty)) if (!TryGetFirst(properties, "DTSTART", out var startProperty))
@@ -105,32 +109,34 @@ public sealed class IcsCalendarParser
TryGetFirst(properties, "LAST-MODIFIED", out var lastModified) TryGetFirst(properties, "LAST-MODIFIED", out var lastModified)
? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime ? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime
: null); : null);
index++;
} }
} }
private static IEnumerable<string> UnfoldLines(string content) private static IEnumerable<string> UnfoldLines(string content)
{ {
string? current = null; StringBuilder? current = null;
using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n')); using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'));
while (reader.ReadLine() is { } line) while (reader.ReadLine() is { } line)
{ {
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null)
{ {
current += line[1..]; current.Append(line[1..]);
continue; continue;
} }
if (current is not null) if (current is not null)
{ {
yield return current; yield return current.ToString();
} }
current = line; current = new StringBuilder(line);
} }
if (current is not null) if (current is not null)
{ {
yield return current; yield return current.ToString();
} }
} }
@@ -309,7 +315,7 @@ public sealed class IcsCalendarParser
return TimeSpan.Zero; return TimeSpan.Zero;
} }
private static IReadOnlyCollection<DateOnly> ExpandStartDates( private static List<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent, IcsRawEvent rawEvent,
DateOnly rangeStart, DateOnly rangeStart,
DateOnly rangeEnd) DateOnly rangeEnd)

View File

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

View File

@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Campaigns.Data; namespace Socialize.Api.Modules.Campaigns.Data;
public static class CampaignModelConfiguration internal static class CampaignModelConfiguration
{ {
public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder)
{ {
@@ -20,6 +22,14 @@ public static class CampaignModelConfiguration
campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique(); campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
campaign.HasIndex(x => x.WorkspaceId); campaign.HasIndex(x => x.WorkspaceId);
campaign.HasIndex(x => x.ClientId); campaign.HasIndex(x => x.ClientId);
campaign.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
campaign.HasOne<Client>()
.WithMany()
.HasForeignKey(x => x.ClientId)
.OnDelete(DeleteBehavior.Restrict);
}); });
return modelBuilder; return modelBuilder;

View File

@@ -6,7 +6,7 @@ using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns.Handlers; namespace Socialize.Api.Modules.Campaigns.Handlers;
public record CreateCampaignRequest( internal record CreateCampaignRequest(
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
string Name, string Name,
@@ -15,7 +15,7 @@ public record CreateCampaignRequest(
string? Description, string? Description,
string? Notes); string? Notes);
public class CreateCampaignRequestValidator internal class CreateCampaignRequestValidator
: Validator<CreateCampaignRequest> : Validator<CreateCampaignRequest>
{ {
public CreateCampaignRequestValidator() public CreateCampaignRequestValidator()
@@ -32,7 +32,7 @@ public class CreateCampaignRequestValidator
} }
} }
public class CreateCampaignHandler( internal class CreateCampaignHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<CreateCampaignRequest, CampaignDto> : Endpoint<CreateCampaignRequest, CampaignDto>

View File

@@ -6,9 +6,9 @@ using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns.Handlers; namespace Socialize.Api.Modules.Campaigns.Handlers;
public record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId); internal record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId);
public record CampaignDto( internal record CampaignDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
Guid ClientId, Guid ClientId,
@@ -19,7 +19,7 @@ public record CampaignDto(
DateTimeOffset StartDate, DateTimeOffset StartDate,
DateTimeOffset EndDate); DateTimeOffset EndDate);
public class GetCampaignsHandler( internal class GetCampaignsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<GetCampaignsRequest, IReadOnlyCollection<CampaignDto>> : Endpoint<GetCampaignsRequest, IReadOnlyCollection<CampaignDto>>
@@ -34,7 +34,7 @@ public class GetCampaignsHandler(
{ {
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable(); IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Channels.Data; namespace Socialize.Api.Modules.Channels.Data;
public static class ChannelModelConfiguration internal static class ChannelModelConfiguration
{ {
public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder)
{ {
@@ -19,6 +20,10 @@ public static class ChannelModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
channel.HasIndex(x => x.WorkspaceId); channel.HasIndex(x => x.WorkspaceId);
channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique(); channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique();
channel.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
return modelBuilder; return modelBuilder;

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Channels.Handlers; namespace Socialize.Api.Modules.Channels.Handlers;
public record ChannelDto( internal record ChannelDto(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
string Name, string Name,

View File

@@ -6,14 +6,14 @@ using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers; namespace Socialize.Api.Modules.Channels.Handlers;
public record CreateChannelRequest( internal record CreateChannelRequest(
Guid WorkspaceId, Guid WorkspaceId,
string Name, string Name,
string Network, string Network,
string? Handle, string? Handle,
string? ExternalUrl); string? ExternalUrl);
public class CreateChannelRequestValidator internal class CreateChannelRequestValidator
: Validator<CreateChannelRequest> : Validator<CreateChannelRequest>
{ {
private static readonly string[] AllowedNetworks = private static readonly string[] AllowedNetworks =
@@ -39,7 +39,7 @@ public class CreateChannelRequestValidator
} }
} }
public class CreateChannelHandler( internal class CreateChannelHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<CreateChannelRequest, ChannelDto> : Endpoint<CreateChannelRequest, ChannelDto>

View File

@@ -6,9 +6,9 @@ using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers; namespace Socialize.Api.Modules.Channels.Handlers;
public record GetChannelsRequest(Guid? WorkspaceId); internal record GetChannelsRequest(Guid? WorkspaceId);
public class GetChannelsHandler( internal class GetChannelsHandler(
AppDbContext dbContext, AppDbContext dbContext,
AccessScopeService accessScopeService) AccessScopeService accessScopeService)
: Endpoint<GetChannelsRequest, IReadOnlyCollection<ChannelDto>> : Endpoint<GetChannelsRequest, IReadOnlyCollection<ChannelDto>>
@@ -23,7 +23,7 @@ public class GetChannelsHandler(
{ {
IQueryable<Channel> query = dbContext.Channels.AsQueryable(); IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId)); query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Channels; namespace Socialize.Api.Modules.Channels;
public static class DependencyInjection internal static class ModuleRegistration
{ {
public static WebApplicationBuilder AddChannelsModule( public static WebApplicationBuilder AddChannelsModule(
this WebApplicationBuilder builder) this WebApplicationBuilder builder)

View File

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

View File

@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Clients.Data; namespace Socialize.Api.Modules.Clients.Data;
public static class ClientModelConfiguration internal static class ClientModelConfiguration
{ {
public static ModelBuilder ConfigureClientsModule(this ModelBuilder modelBuilder) public static ModelBuilder ConfigureClientsModule(this ModelBuilder modelBuilder)
{ {
@@ -21,6 +22,10 @@ public static class ClientModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
client.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique(); client.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique();
client.HasIndex(x => x.WorkspaceId); client.HasIndex(x => x.WorkspaceId);
client.HasOne<Workspace>()
.WithMany()
.HasForeignKey(x => x.WorkspaceId)
.OnDelete(DeleteBehavior.Restrict);
}); });
return modelBuilder; return modelBuilder;

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