From df3e602015c0f5c8d57ea9b1c75ab761879d41f9 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 24 Apr 2026 12:58:35 -0400 Subject: [PATCH] feat: pivot to social media workflow app --- .github/workflows/backend-ci.yml | 4 +- AGENTS.md | 135 +- README.md | 152 +-- SOCIALIZE.fr.md | 328 ----- TEMPLATE_PROMPT.md | 327 +++++ backend/Common/Domain/Entity.cs | 2 +- backend/Data/AppDbContext.cs | 226 ++++ backend/DependencyInjection.cs | 29 +- backend/GlobalUsings.cs | 12 +- .../BlobStorage/Contracts/CommonFileNames.cs | 3 +- .../BlobStorage/Contracts/ContainerNames.cs | 3 +- .../BlobStorage/Contracts/ContentTypes.cs | 2 +- .../BlobStorage/Contracts/IBlobStorage.cs | 2 +- .../Contracts/SubDirectoryNames.cs | 2 +- .../BlobStorage/Services/AzureBlobStorage.cs | 4 +- .../Configuration/WebsiteOptions.cs | 4 +- backend/Infrastructure/DependencyInjection.cs | 24 +- .../Development/DevelopmentSeedExtensions.cs | 633 +++++++++ .../Development/DevelopmentSeedOptions.cs | 8 + .../Emailer/Configuration/EmailerOptions.cs | 2 +- .../Emailer/Contracts/IEmailSender.cs | 2 +- .../Emailer/Services/LoggerEmailSender.cs | 4 +- .../Emailer/Services/PostmarkEmailSender.cs | 6 +- .../Emailer/Services/ResendEmailSender.cs | 6 +- .../Stripe/Configuration/StripeOptions.cs | 4 +- .../MembershipCancellationProcessor.cs | 28 - .../Services/MembershipPaymentProcessor.cs | 68 - .../Services/MembershipTierProcessor.cs | 43 - .../Stripe/Services/StripeTipProcessor.cs | 70 - .../Security/AccessScopeService.cs | 56 + .../Security/ClaimsPrincipalExtensions.cs | 31 +- .../Security/GenerateJwtToken.cs | 18 +- .../Infrastructure/Security/KnownClaims.cs | 6 +- .../Security/MissingClaimException.cs | 2 +- .../Security/PasswordGenerator.cs | 2 +- .../Security/RefreshTokenGenerator.cs | 2 +- .../YouTube/YouTubeUrlHelper.cs | 2 +- .../20260423061407_Initial.Designer.cs | 942 +++++++++++++ backend/Migrations/20260423061407_Initial.cs | 657 +++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 939 +++++++++++++ .../Approvals/Data/ApprovalDecision.cs | 13 + .../Modules/Approvals/Data/ApprovalRequest.cs | 17 + .../Modules/Approvals/DependencyInjection.cs | 12 + .../Handlers/CreateApprovalRequest.cs | 118 ++ .../Approvals/Handlers/GetApprovals.cs | 117 ++ .../Handlers/SubmitApprovalDecision.cs | 169 +++ backend/Modules/Assets/Data/Asset.cs | 16 + backend/Modules/Assets/Data/AssetRevision.cs | 13 + backend/Modules/Assets/DependencyInjection.cs | 12 + .../Assets/Handlers/CreateAssetRevision.cs | 102 ++ .../Assets/Handlers/CreateGoogleDriveAsset.cs | 130 ++ backend/Modules/Assets/Handlers/GetAssets.cs | 89 ++ backend/Modules/Clients/Data/Client.cs | 14 + .../Modules/Clients/DependencyInjection.cs | 12 + .../Clients/Handlers/ChangeClientPortrait.cs | 65 + .../Modules/Clients/Handlers/CreateClient.cs | 101 ++ .../Modules/Clients/Handlers/GetClients.cs | 73 + .../Modules/Clients/Handlers/UpdateClient.cs | 98 ++ backend/Modules/Comments/Data/Comment.cs | 16 + .../Modules/Comments/DependencyInjection.cs | 12 + .../Comments/Handlers/CreateComment.cs | 120 ++ .../Modules/Comments/Handlers/GetComments.cs | 80 ++ .../Comments/Handlers/ResolveComment.cs | 84 ++ .../Modules/ContentItems/Data/ContentItem.cs | 18 + .../ContentItems/Data/ContentItemRevision.cs | 16 + .../ContentItems/DependencyInjection.cs | 12 + .../Handlers/CreateContentItem.cs | 148 ++ .../Handlers/CreateContentItemRevision.cs | 120 ++ .../ContentItems/Handlers/GetContentItem.cs | 68 + .../Handlers/GetContentItemRevisions.cs | 64 + .../ContentItems/Handlers/GetContentItems.cs | 91 ++ .../Handlers/UpdateContentItemStatus.cs | 105 ++ backend/Modules/Contents/Data/Album.cs | 11 - backend/Modules/Contents/Data/AlbumPhoto.cs | 15 - .../Contents/Data/ContentsDbContext.cs | 56 - .../Modules/Contents/DependencyInjection.cs | 27 - .../Contents/Features/AddPhotoToAlbum.cs | 195 --- .../Modules/Contents/Features/CreateAlbum.cs | 70 - backend/Modules/Contents/Features/GetAlbum.cs | 83 -- .../Modules/Contents/Features/RemoveAlbum.cs | 66 - .../Contents/Features/RemovePhotoFromAlbum.cs | 73 - .../20250609212411_Initial.Designer.cs | 134 -- .../Migrations/20250609212411_Initial.cs | 83 -- .../ContentsDbContextModelSnapshot.cs | 131 -- .../Modules/Contents/Models/ContentModel.cs | 18 - .../Modules/Contents/Models/FollowModel.cs | 7 - .../Creators/Configuration/CreatorOptions.cs | 8 - .../Creators/Contracts/CreatorReference.cs | 9 - .../Creators/Contracts/ICreatorLookup.cs | 6 - backend/Modules/Creators/Data/Creator.cs | 32 - .../Creators/Data/CreatorsDbContext.cs | 46 - backend/Modules/Creators/Data/Presentation.cs | 11 - backend/Modules/Creators/Data/Slugs.cs | 14 - backend/Modules/Creators/Data/Socials.cs | 15 - .../Modules/Creators/DependencyInjection.cs | 35 - .../Modules/Creators/Features/ChangeBanner.cs | 60 - .../Modules/Creators/Features/ChangeEmail.cs | 67 - .../Modules/Creators/Features/ChangeLogo.cs | 74 - .../Modules/Creators/Features/ChangeName.cs | 49 - .../Creators/Features/ChangePhoneNumber.cs | 67 - .../Features/ChangePresentationInfos.cs | 71 - .../Modules/Creators/Features/ChangeSlug.cs | 98 -- .../Creators/Features/ChangeSocials.cs | 50 - .../Modules/Creators/Features/ChangeTitle.cs | 37 - .../Creators/Features/CheckStatusStripe.cs | 68 - .../Creators/Features/ConnectStripe.cs | 91 -- .../Creators/Features/CreateCreator.cs | 80 -- .../Creators/Features/GetCreatorById.cs | 54 - .../Creators/Features/GetCreatorBySlug.cs | 105 -- .../Creators/Features/GetCreatorProfile.cs | 76 -- .../Creators/Features/RemoveCreator.cs | 63 - .../Modules/Creators/Features/ReserveSlug.cs | 109 -- .../Creators/Features/RestoreCreator.cs | 64 - .../Modules/Creators/Features/RevokeStripe.cs | 48 - .../20250609203815_Initial.Designer.cs | 221 --- .../Migrations/20250609203815_Initial.cs | 141 -- .../20250610200446_AddStripe.Designer.cs | 218 --- .../Migrations/20250610200446_AddStripe.cs | 43 - .../CreatorsDbContextModelSnapshot.cs | 215 --- .../Creators/Services/CreatorLookup.cs | 26 - .../Modules/Creators/Services/SlugPurger.cs | 43 - .../Identity/Configuration/JwtOptions.cs | 2 +- .../Modules/Identity/Contracts/IUserLookup.cs | 2 +- .../Modules/Identity/Contracts/KnownRoles.cs | 7 +- .../Identity/Contracts/UserReference.cs | 2 +- .../Identity/Data/IdentityDbContext.cs | 18 - .../Modules/Identity/Data/IdentityService.cs | 24 +- backend/Modules/Identity/Data/Role.cs | 2 +- backend/Modules/Identity/Data/User.cs | 2 +- backend/Modules/Identity/Data/UserManager.cs | 2 +- .../Modules/Identity/DependencyInjection.cs | 60 +- .../Identity/Handlers/ChangeAddress.cs | 6 +- .../Modules/Identity/Handlers/ChangeAlias.cs | 6 +- .../Identity/Handlers/ChangeBirthDate.cs | 6 +- .../Modules/Identity/Handlers/ChangeEmail.cs | 6 +- .../Identity/Handlers/ChangeFullname.cs | 6 +- .../Modules/Identity/Handlers/ChangePhone.cs | 6 +- .../Identity/Handlers/ChangePortrait.cs | 8 +- .../Identity/Handlers/ForgotPassword.cs | 12 +- .../Identity/Handlers/GetCurrentUser.cs | 39 +- .../Handlers/GetCurrentUserProfilePicture.cs | 8 +- backend/Modules/Identity/Handlers/Login.cs | 25 +- .../Identity/Handlers/LoginWithFacebook.cs | 24 +- .../Identity/Handlers/LoginWithGoogle.cs | 24 +- .../Modules/Identity/Handlers/RefreshToken.cs | 24 +- backend/Modules/Identity/Handlers/Register.cs | 6 +- .../Identity/Handlers/ResendVerification.cs | 6 +- .../Identity/Handlers/ResetPassword.cs | 4 +- .../Modules/Identity/Handlers/SetPassword.cs | 6 +- .../Modules/Identity/Handlers/VerifyEmail.cs | 4 +- .../Identity/IdentityResultExtensions.cs | 4 +- .../20250609203622_Initial.Designer.cs | 315 ----- .../Migrations/20250609203622_Initial.cs | 263 ---- .../IdentityDbContextModelSnapshot.cs | 312 ----- backend/Modules/Identity/Models/Result.cs | 2 +- backend/Modules/Identity/Models/RoleModel.cs | 2 +- backend/Modules/Identity/Models/UserDto.cs | 6 +- backend/Modules/Identity/Models/UserModel.cs | 2 +- .../Identity/Services/AccessTokenFactory.cs | 43 + .../Services/EmailVerificationService.cs | 10 +- .../Modules/Identity/Services/UserLookup.cs | 6 +- .../IMembershipCancellationProcessor.cs | 6 - .../Contracts/IMembershipNotifier.cs | 26 - .../Contracts/IMembershipPaymentProcessor.cs | 14 - .../Contracts/IMembershipTierProcessor.cs | 11 - .../Contracts/MembershipCheckoutSession.cs | 6 - .../Modules/Memberships/Data/Membership.cs | 20 - .../Memberships/Data/MembershipState.cs | 8 - .../Memberships/Data/MembershipTier.cs | 17 - .../Memberships/Data/MembershipsDbContext.cs | 36 - backend/Modules/Memberships/Data/Payment.cs | 12 - .../Memberships/DependencyInjection.cs | 32 - .../Memberships/Handlers/CancelMembership.cs | 49 - .../Handlers/CreateMembershipTier.cs | 56 - .../Handlers/GetActiveMemberships.cs | 54 - .../Handlers/GetMembershipTiers.cs | 52 - .../Handlers/StripeWebhookEndpoint.cs | 119 -- .../Handlers/SubscribeToCreator.cs | 83 -- .../20250609212641_Initial.Designer.cs | 190 --- .../Migrations/20250609212641_Initial.cs | 119 -- .../MembershipsDbContextModelSnapshot.cs | 187 --- .../Services/MembershipNotifier.cs | 109 -- backend/Modules/Messaging/Data/Message.cs | 11 - .../Messaging/Data/MessagingDbContext.cs | 93 -- .../Modules/Messaging/DependencyInjection.cs | 27 - .../Modules/Messaging/Handlers/AddMessage.cs | 55 - .../Modules/Messaging/Handlers/AddReply.cs | 61 - .../Messaging/Handlers/ChangeMessage.cs | 64 - .../Messaging/Handlers/DeleteMessage.cs | 52 - .../Messaging/Handlers/GetMessageCount.cs | 41 - .../Modules/Messaging/Handlers/GetMessages.cs | 42 - .../Messaging/Handlers/GetMessagesByUser.cs | 59 - .../Modules/Messaging/Handlers/GetReplies.cs | 43 - .../20250609171331_Initial.Designer.cs | 67 - .../Migrations/20250609171331_Initial.cs | 45 - .../MessagingDbContextModelSnapshot.cs | 64 - .../Modules/Messaging/Models/MessageDto.cs | 12 - .../Contracts/INotificationEventWriter.cs | 17 + .../Notifications/Data/NotificationEvent.cs | 17 + .../Notifications/DependencyInjection.cs | 16 + .../Handlers/GetNotifications.cs | 88 ++ .../Handlers/MarkNotificationAsRead.cs | 39 + .../Services/NotificationEventWriter.cs | 30 + backend/Modules/Projects/Data/Project.cs | 15 + .../Modules/Projects/DependencyInjection.cs | 12 + .../Projects/Handlers/CreateProject.cs | 115 ++ .../Modules/Projects/Handlers/GetProjects.cs | 86 ++ .../Tipping/Contracts/ITipPaymentNotifier.cs | 10 - .../Tipping/Contracts/ITipProcessor.cs | 16 - .../Tipping/Contracts/TipCheckoutSession.cs | 5 - backend/Modules/Tipping/Data/Tip.cs | 21 - .../Modules/Tipping/Data/TippingDbContext.cs | 22 - .../Modules/Tipping/DependencyInjection.cs | 31 - .../Tipping/Handlers/GetReceivedTips.cs | 49 - backend/Modules/Tipping/Handlers/SendTip.cs | 124 -- .../20250609171342_Initial.Designer.cs | 80 -- .../Migrations/20250609171342_Initial.cs | 49 - .../20250731175148_TippingIssues.Designer.cs | 84 -- .../20250731175148_TippingIssues.cs | 100 -- .../TippingDbContextModelSnapshot.cs | 81 -- .../Tipping/Models/TipReceivedModel.cs | 11 - .../Tipping/Services/TipPaymentNotifier.cs | 120 -- backend/Modules/Workspaces/Data/Workspace.cs | 11 + .../Workspaces/Data/WorkspaceInvite.cs | 12 + .../Modules/Workspaces/DependencyInjection.cs | 16 + .../Workspaces/Handlers/CreateWorkspace.cs | 80 ++ .../Handlers/CreateWorkspaceInvite.cs | 100 ++ .../Handlers/GetWorkspaceInvites.cs | 49 + .../Handlers/GetWorkspaceMembers.cs | 96 ++ .../Workspaces/Handlers/GetWorkspaces.cs | 46 + backend/Program.cs | 75 +- backend/Properties/launchSettings.json | 4 +- .../{Hutopy.csproj => Socialize.Api.csproj} | 30 +- backend/appsettings.Development.json | 12 +- backend/appsettings.Production.json | 2 +- backend/appsettings.json | 2 +- backend/backend.sln | 2 +- backend/scripts/add-migration.sh | 2 +- backend/scripts/start-infrastructure.sh | 19 +- backend/scripts/update-databases.sh | 2 +- docs/LLM_DEVELOPMENT_WORKFLOW.md | 593 ++++++++ docs/README.md | 32 + PLAN.md => docs/archive/PLAN.md | 4 +- SOCIALIZE.md => docs/archive/SOCIALIZE.md | 0 Stripe.md => docs/archive/Stripe.md | 2 + docs/archive/WORKSHEET.md | 35 + docs/constraints.md | 44 + docs/decisions/ADR-TEMPLATE.md | 31 + docs/decisions/README.md | 26 + docs/product/glossary.md | 120 ++ docs/product/vision.md | 111 ++ docs/specs/TEMPLATE.md | 61 + docs/specs/content-approval-workflow.md | 98 ++ docs/use-cases/review-workflows.md | 90 ++ frontend/.env.development | 6 +- frontend/.env.production | 5 +- frontend/.gitignore | 4 - frontend/README.md | 8 +- frontend/SSL-dev.md | 137 -- frontend/index.html | 4 +- frontend/localhost-key.pem | 28 - frontend/localhost.pem | 25 - frontend/src/App.vue | 105 +- frontend/src/assets/main.css | 28 +- frontend/src/components/AppAvatar.vue | 79 ++ .../src/components/ImageCropperDialog.vue | 365 +++++ frontend/src/composables/useFacebookLogin.js | 6 +- frontend/src/config.js | 23 +- frontend/src/locales/en.json | 430 +++++- frontend/src/locales/fr.json | 430 +++++- frontend/src/main.js | 32 +- frontend/src/plugins/api.js | 7 +- frontend/src/plugins/i18n.js | 12 + frontend/src/router/router.js | 192 ++- frontend/src/stores/authStore.js | 24 + frontend/src/stores/brandingStore.js | 96 -- frontend/src/stores/channelsStore.js | 122 ++ frontend/src/stores/clientsStore.js | 182 +++ frontend/src/stores/contentItemDetailStore.js | 255 ++++ frontend/src/stores/contentItemsStore.js | 112 ++ frontend/src/stores/creatorProfileStore.js | 86 -- frontend/src/stores/languageStore.js | 16 +- frontend/src/stores/notificationsStore.js | 89 ++ frontend/src/stores/projectsStore.js | 99 ++ frontend/src/stores/reviewQueueStore.js | 49 + frontend/src/stores/userProfileStore.js | 15 +- frontend/src/stores/workspaceStore.js | 208 +++ frontend/src/views/app/ChannelsView.vue | 376 ++++++ frontend/src/views/app/ClientDetailView.vue | 712 ++++++++++ frontend/src/views/app/ClientsView.vue | 366 +++++ .../src/views/app/ContentItemDetailView.vue | 1201 +++++++++++++++++ frontend/src/views/app/ContentItemsView.vue | 146 ++ frontend/src/views/app/DashboardView.vue | 620 +++++++++ .../views/app/IntegrationsSettingsView.vue | 94 ++ frontend/src/views/app/MediaLibraryView.vue | 222 +++ frontend/src/views/app/OverviewView.vue | 418 ++++++ frontend/src/views/app/ProjectDetailView.vue | 232 ++++ frontend/src/views/app/ProjectsView.vue | 376 ++++++ frontend/src/views/app/ReviewQueueView.vue | 102 ++ frontend/src/views/app/SettingsLayoutView.vue | 93 ++ frontend/src/views/app/UserSettingsView.vue | 163 +++ .../src/views/app/WorkspaceCreateView.vue | 271 ++++ .../src/views/app/WorkspaceSettingsView.vue | 559 ++++++++ frontend/src/views/creators/AboutCreator.vue | 686 ---------- frontend/src/views/creators/ActualBanner.vue | 81 -- frontend/src/views/creators/AlbumEditor.vue | 367 ----- frontend/src/views/creators/AlbumView.vue | 134 -- frontend/src/views/creators/AlbumViewer.vue | 207 --- frontend/src/views/creators/BannerActions.vue | 170 --- frontend/src/views/creators/BannerEditor.vue | 377 ------ frontend/src/views/creators/CreateCreator.vue | 131 -- frontend/src/views/creators/CreatorHome.vue | 69 - frontend/src/views/creators/CreatorLayout.vue | 146 -- frontend/src/views/creators/CreatorLogo.vue | 92 -- .../src/views/creators/CreatorLogoEditor.vue | 398 ------ .../src/views/creators/DonationButton.vue | 74 - .../src/views/creators/DonationDialog.vue | 45 - frontend/src/views/creators/DonationForm.vue | 188 --- frontend/src/views/creators/NameEditor.vue | 223 --- frontend/src/views/creators/NameTitle.vue | 39 - .../src/views/creators/PaymentCompleted.vue | 148 -- frontend/src/views/creators/PaymentFailed.vue | 119 -- frontend/src/views/documentation/About.vue | 194 --- .../src/views/documentation/ContentPolicy.vue | 275 ---- .../src/views/documentation/CreatorGuide.vue | 72 - .../documentation/DocumentationLayout.vue | 12 - frontend/src/views/documentation/FAQ.vue | 84 -- .../views/documentation/HelpAndContact.vue | 57 - frontend/src/views/documentation/Pricing.vue | 39 - .../documentation/TermsAndConditions.vue | 105 -- .../src/views/documentation/documentation.css | 25 - frontend/src/views/main/AppBar.vue | 356 +++++ frontend/src/views/main/AppSidebar.vue | 895 ++++++++++++ frontend/src/views/main/Footer.vue | 139 -- frontend/src/views/main/Landing.vue | 405 ++---- frontend/src/views/main/SiteBar.vue | 192 --- frontend/src/views/profile/ProfilePage.vue | 953 ------------- .../src/views/profile/account/AliasDialog.vue | 58 - .../profile/account/ChangePasswordDialog.vue | 177 --- .../src/views/profile/account/EmailDialog.vue | 78 -- .../views/profile/account/FullnameDialog.vue | 69 - .../profile/creators/ChangeEmailDialog.vue | 164 --- .../profile/creators/ChangeNameDialog.vue | 82 -- .../profile/creators/ChangePhoneDialog.vue | 239 ---- .../profile/creators/ChangeSlugDialog.vue | 120 -- .../profile/creators/ChangeStripeIdDialog.vue | 84 -- .../profile/creators/ChangeTitleDialog.vue | 82 -- .../views/profile/creators/SocialsDialog.vue | 200 --- frontend/vite.config.js | 18 +- 349 files changed, 18685 insertions(+), 16010 deletions(-) delete mode 100644 SOCIALIZE.fr.md create mode 100644 TEMPLATE_PROMPT.md create mode 100644 backend/Data/AppDbContext.cs create mode 100644 backend/Infrastructure/Development/DevelopmentSeedExtensions.cs create mode 100644 backend/Infrastructure/Development/DevelopmentSeedOptions.cs delete mode 100644 backend/Infrastructure/Payments/Stripe/Services/MembershipCancellationProcessor.cs delete mode 100644 backend/Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs delete mode 100644 backend/Infrastructure/Payments/Stripe/Services/MembershipTierProcessor.cs delete mode 100644 backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs create mode 100644 backend/Infrastructure/Security/AccessScopeService.cs create mode 100644 backend/Migrations/20260423061407_Initial.Designer.cs create mode 100644 backend/Migrations/20260423061407_Initial.cs create mode 100644 backend/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/Modules/Approvals/Data/ApprovalDecision.cs create mode 100644 backend/Modules/Approvals/Data/ApprovalRequest.cs create mode 100644 backend/Modules/Approvals/DependencyInjection.cs create mode 100644 backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs create mode 100644 backend/Modules/Approvals/Handlers/GetApprovals.cs create mode 100644 backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs create mode 100644 backend/Modules/Assets/Data/Asset.cs create mode 100644 backend/Modules/Assets/Data/AssetRevision.cs create mode 100644 backend/Modules/Assets/DependencyInjection.cs create mode 100644 backend/Modules/Assets/Handlers/CreateAssetRevision.cs create mode 100644 backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs create mode 100644 backend/Modules/Assets/Handlers/GetAssets.cs create mode 100644 backend/Modules/Clients/Data/Client.cs create mode 100644 backend/Modules/Clients/DependencyInjection.cs create mode 100644 backend/Modules/Clients/Handlers/ChangeClientPortrait.cs create mode 100644 backend/Modules/Clients/Handlers/CreateClient.cs create mode 100644 backend/Modules/Clients/Handlers/GetClients.cs create mode 100644 backend/Modules/Clients/Handlers/UpdateClient.cs create mode 100644 backend/Modules/Comments/Data/Comment.cs create mode 100644 backend/Modules/Comments/DependencyInjection.cs create mode 100644 backend/Modules/Comments/Handlers/CreateComment.cs create mode 100644 backend/Modules/Comments/Handlers/GetComments.cs create mode 100644 backend/Modules/Comments/Handlers/ResolveComment.cs create mode 100644 backend/Modules/ContentItems/Data/ContentItem.cs create mode 100644 backend/Modules/ContentItems/Data/ContentItemRevision.cs create mode 100644 backend/Modules/ContentItems/DependencyInjection.cs create mode 100644 backend/Modules/ContentItems/Handlers/CreateContentItem.cs create mode 100644 backend/Modules/ContentItems/Handlers/CreateContentItemRevision.cs create mode 100644 backend/Modules/ContentItems/Handlers/GetContentItem.cs create mode 100644 backend/Modules/ContentItems/Handlers/GetContentItemRevisions.cs create mode 100644 backend/Modules/ContentItems/Handlers/GetContentItems.cs create mode 100644 backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs delete mode 100644 backend/Modules/Contents/Data/Album.cs delete mode 100644 backend/Modules/Contents/Data/AlbumPhoto.cs delete mode 100644 backend/Modules/Contents/Data/ContentsDbContext.cs delete mode 100644 backend/Modules/Contents/DependencyInjection.cs delete mode 100644 backend/Modules/Contents/Features/AddPhotoToAlbum.cs delete mode 100644 backend/Modules/Contents/Features/CreateAlbum.cs delete mode 100644 backend/Modules/Contents/Features/GetAlbum.cs delete mode 100644 backend/Modules/Contents/Features/RemoveAlbum.cs delete mode 100644 backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs delete mode 100644 backend/Modules/Contents/Migrations/20250609212411_Initial.Designer.cs delete mode 100644 backend/Modules/Contents/Migrations/20250609212411_Initial.cs delete mode 100644 backend/Modules/Contents/Migrations/ContentsDbContextModelSnapshot.cs delete mode 100644 backend/Modules/Contents/Models/ContentModel.cs delete mode 100644 backend/Modules/Contents/Models/FollowModel.cs delete mode 100644 backend/Modules/Creators/Configuration/CreatorOptions.cs delete mode 100644 backend/Modules/Creators/Contracts/CreatorReference.cs delete mode 100644 backend/Modules/Creators/Contracts/ICreatorLookup.cs delete mode 100644 backend/Modules/Creators/Data/Creator.cs delete mode 100644 backend/Modules/Creators/Data/CreatorsDbContext.cs delete mode 100644 backend/Modules/Creators/Data/Presentation.cs delete mode 100644 backend/Modules/Creators/Data/Slugs.cs delete mode 100644 backend/Modules/Creators/Data/Socials.cs delete mode 100644 backend/Modules/Creators/DependencyInjection.cs delete mode 100644 backend/Modules/Creators/Features/ChangeBanner.cs delete mode 100644 backend/Modules/Creators/Features/ChangeEmail.cs delete mode 100644 backend/Modules/Creators/Features/ChangeLogo.cs delete mode 100644 backend/Modules/Creators/Features/ChangeName.cs delete mode 100644 backend/Modules/Creators/Features/ChangePhoneNumber.cs delete mode 100644 backend/Modules/Creators/Features/ChangePresentationInfos.cs delete mode 100644 backend/Modules/Creators/Features/ChangeSlug.cs delete mode 100644 backend/Modules/Creators/Features/ChangeSocials.cs delete mode 100644 backend/Modules/Creators/Features/ChangeTitle.cs delete mode 100644 backend/Modules/Creators/Features/CheckStatusStripe.cs delete mode 100644 backend/Modules/Creators/Features/ConnectStripe.cs delete mode 100644 backend/Modules/Creators/Features/CreateCreator.cs delete mode 100644 backend/Modules/Creators/Features/GetCreatorById.cs delete mode 100644 backend/Modules/Creators/Features/GetCreatorBySlug.cs delete mode 100644 backend/Modules/Creators/Features/GetCreatorProfile.cs delete mode 100644 backend/Modules/Creators/Features/RemoveCreator.cs delete mode 100644 backend/Modules/Creators/Features/ReserveSlug.cs delete mode 100644 backend/Modules/Creators/Features/RestoreCreator.cs delete mode 100644 backend/Modules/Creators/Features/RevokeStripe.cs delete mode 100644 backend/Modules/Creators/Migrations/20250609203815_Initial.Designer.cs delete mode 100644 backend/Modules/Creators/Migrations/20250609203815_Initial.cs delete mode 100644 backend/Modules/Creators/Migrations/20250610200446_AddStripe.Designer.cs delete mode 100644 backend/Modules/Creators/Migrations/20250610200446_AddStripe.cs delete mode 100644 backend/Modules/Creators/Migrations/CreatorsDbContextModelSnapshot.cs delete mode 100644 backend/Modules/Creators/Services/CreatorLookup.cs delete mode 100644 backend/Modules/Creators/Services/SlugPurger.cs delete mode 100644 backend/Modules/Identity/Data/IdentityDbContext.cs delete mode 100644 backend/Modules/Identity/Migrations/20250609203622_Initial.Designer.cs delete mode 100644 backend/Modules/Identity/Migrations/20250609203622_Initial.cs delete mode 100644 backend/Modules/Identity/Migrations/IdentityDbContextModelSnapshot.cs create mode 100644 backend/Modules/Identity/Services/AccessTokenFactory.cs delete mode 100644 backend/Modules/Memberships/Contracts/IMembershipCancellationProcessor.cs delete mode 100644 backend/Modules/Memberships/Contracts/IMembershipNotifier.cs delete mode 100644 backend/Modules/Memberships/Contracts/IMembershipPaymentProcessor.cs delete mode 100644 backend/Modules/Memberships/Contracts/IMembershipTierProcessor.cs delete mode 100644 backend/Modules/Memberships/Contracts/MembershipCheckoutSession.cs delete mode 100644 backend/Modules/Memberships/Data/Membership.cs delete mode 100644 backend/Modules/Memberships/Data/MembershipState.cs delete mode 100644 backend/Modules/Memberships/Data/MembershipTier.cs delete mode 100644 backend/Modules/Memberships/Data/MembershipsDbContext.cs delete mode 100644 backend/Modules/Memberships/Data/Payment.cs delete mode 100644 backend/Modules/Memberships/DependencyInjection.cs delete mode 100644 backend/Modules/Memberships/Handlers/CancelMembership.cs delete mode 100644 backend/Modules/Memberships/Handlers/CreateMembershipTier.cs delete mode 100644 backend/Modules/Memberships/Handlers/GetActiveMemberships.cs delete mode 100644 backend/Modules/Memberships/Handlers/GetMembershipTiers.cs delete mode 100644 backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs delete mode 100644 backend/Modules/Memberships/Handlers/SubscribeToCreator.cs delete mode 100644 backend/Modules/Memberships/Migrations/20250609212641_Initial.Designer.cs delete mode 100644 backend/Modules/Memberships/Migrations/20250609212641_Initial.cs delete mode 100644 backend/Modules/Memberships/Migrations/MembershipsDbContextModelSnapshot.cs delete mode 100644 backend/Modules/Memberships/Services/MembershipNotifier.cs delete mode 100644 backend/Modules/Messaging/Data/Message.cs delete mode 100644 backend/Modules/Messaging/Data/MessagingDbContext.cs delete mode 100644 backend/Modules/Messaging/DependencyInjection.cs delete mode 100644 backend/Modules/Messaging/Handlers/AddMessage.cs delete mode 100644 backend/Modules/Messaging/Handlers/AddReply.cs delete mode 100644 backend/Modules/Messaging/Handlers/ChangeMessage.cs delete mode 100644 backend/Modules/Messaging/Handlers/DeleteMessage.cs delete mode 100644 backend/Modules/Messaging/Handlers/GetMessageCount.cs delete mode 100644 backend/Modules/Messaging/Handlers/GetMessages.cs delete mode 100644 backend/Modules/Messaging/Handlers/GetMessagesByUser.cs delete mode 100644 backend/Modules/Messaging/Handlers/GetReplies.cs delete mode 100644 backend/Modules/Messaging/Migrations/20250609171331_Initial.Designer.cs delete mode 100644 backend/Modules/Messaging/Migrations/20250609171331_Initial.cs delete mode 100644 backend/Modules/Messaging/Migrations/MessagingDbContextModelSnapshot.cs delete mode 100644 backend/Modules/Messaging/Models/MessageDto.cs create mode 100644 backend/Modules/Notifications/Contracts/INotificationEventWriter.cs create mode 100644 backend/Modules/Notifications/Data/NotificationEvent.cs create mode 100644 backend/Modules/Notifications/DependencyInjection.cs create mode 100644 backend/Modules/Notifications/Handlers/GetNotifications.cs create mode 100644 backend/Modules/Notifications/Handlers/MarkNotificationAsRead.cs create mode 100644 backend/Modules/Notifications/Services/NotificationEventWriter.cs create mode 100644 backend/Modules/Projects/Data/Project.cs create mode 100644 backend/Modules/Projects/DependencyInjection.cs create mode 100644 backend/Modules/Projects/Handlers/CreateProject.cs create mode 100644 backend/Modules/Projects/Handlers/GetProjects.cs delete mode 100644 backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs delete mode 100644 backend/Modules/Tipping/Contracts/ITipProcessor.cs delete mode 100644 backend/Modules/Tipping/Contracts/TipCheckoutSession.cs delete mode 100644 backend/Modules/Tipping/Data/Tip.cs delete mode 100644 backend/Modules/Tipping/Data/TippingDbContext.cs delete mode 100644 backend/Modules/Tipping/DependencyInjection.cs delete mode 100644 backend/Modules/Tipping/Handlers/GetReceivedTips.cs delete mode 100644 backend/Modules/Tipping/Handlers/SendTip.cs delete mode 100644 backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs delete mode 100644 backend/Modules/Tipping/Migrations/20250609171342_Initial.cs delete mode 100644 backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.Designer.cs delete mode 100644 backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.cs delete mode 100644 backend/Modules/Tipping/Migrations/TippingDbContextModelSnapshot.cs delete mode 100644 backend/Modules/Tipping/Models/TipReceivedModel.cs delete mode 100644 backend/Modules/Tipping/Services/TipPaymentNotifier.cs create mode 100644 backend/Modules/Workspaces/Data/Workspace.cs create mode 100644 backend/Modules/Workspaces/Data/WorkspaceInvite.cs create mode 100644 backend/Modules/Workspaces/DependencyInjection.cs create mode 100644 backend/Modules/Workspaces/Handlers/CreateWorkspace.cs create mode 100644 backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs create mode 100644 backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs create mode 100644 backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs create mode 100644 backend/Modules/Workspaces/Handlers/GetWorkspaces.cs rename backend/{Hutopy.csproj => Socialize.Api.csproj} (80%) create mode 100644 docs/LLM_DEVELOPMENT_WORKFLOW.md create mode 100644 docs/README.md rename PLAN.md => docs/archive/PLAN.md (98%) rename SOCIALIZE.md => docs/archive/SOCIALIZE.md (100%) rename Stripe.md => docs/archive/Stripe.md (84%) create mode 100644 docs/archive/WORKSHEET.md create mode 100644 docs/constraints.md create mode 100644 docs/decisions/ADR-TEMPLATE.md create mode 100644 docs/decisions/README.md create mode 100644 docs/product/glossary.md create mode 100644 docs/product/vision.md create mode 100644 docs/specs/TEMPLATE.md create mode 100644 docs/specs/content-approval-workflow.md create mode 100644 docs/use-cases/review-workflows.md delete mode 100644 frontend/SSL-dev.md delete mode 100644 frontend/localhost-key.pem delete mode 100644 frontend/localhost.pem create mode 100644 frontend/src/components/AppAvatar.vue create mode 100644 frontend/src/components/ImageCropperDialog.vue create mode 100644 frontend/src/plugins/i18n.js delete mode 100644 frontend/src/stores/brandingStore.js create mode 100644 frontend/src/stores/channelsStore.js create mode 100644 frontend/src/stores/clientsStore.js create mode 100644 frontend/src/stores/contentItemDetailStore.js create mode 100644 frontend/src/stores/contentItemsStore.js delete mode 100644 frontend/src/stores/creatorProfileStore.js create mode 100644 frontend/src/stores/notificationsStore.js create mode 100644 frontend/src/stores/projectsStore.js create mode 100644 frontend/src/stores/reviewQueueStore.js create mode 100644 frontend/src/stores/workspaceStore.js create mode 100644 frontend/src/views/app/ChannelsView.vue create mode 100644 frontend/src/views/app/ClientDetailView.vue create mode 100644 frontend/src/views/app/ClientsView.vue create mode 100644 frontend/src/views/app/ContentItemDetailView.vue create mode 100644 frontend/src/views/app/ContentItemsView.vue create mode 100644 frontend/src/views/app/DashboardView.vue create mode 100644 frontend/src/views/app/IntegrationsSettingsView.vue create mode 100644 frontend/src/views/app/MediaLibraryView.vue create mode 100644 frontend/src/views/app/OverviewView.vue create mode 100644 frontend/src/views/app/ProjectDetailView.vue create mode 100644 frontend/src/views/app/ProjectsView.vue create mode 100644 frontend/src/views/app/ReviewQueueView.vue create mode 100644 frontend/src/views/app/SettingsLayoutView.vue create mode 100644 frontend/src/views/app/UserSettingsView.vue create mode 100644 frontend/src/views/app/WorkspaceCreateView.vue create mode 100644 frontend/src/views/app/WorkspaceSettingsView.vue delete mode 100644 frontend/src/views/creators/AboutCreator.vue delete mode 100644 frontend/src/views/creators/ActualBanner.vue delete mode 100644 frontend/src/views/creators/AlbumEditor.vue delete mode 100644 frontend/src/views/creators/AlbumView.vue delete mode 100644 frontend/src/views/creators/AlbumViewer.vue delete mode 100644 frontend/src/views/creators/BannerActions.vue delete mode 100644 frontend/src/views/creators/BannerEditor.vue delete mode 100644 frontend/src/views/creators/CreateCreator.vue delete mode 100644 frontend/src/views/creators/CreatorHome.vue delete mode 100644 frontend/src/views/creators/CreatorLayout.vue delete mode 100644 frontend/src/views/creators/CreatorLogo.vue delete mode 100644 frontend/src/views/creators/CreatorLogoEditor.vue delete mode 100644 frontend/src/views/creators/DonationButton.vue delete mode 100644 frontend/src/views/creators/DonationDialog.vue delete mode 100644 frontend/src/views/creators/DonationForm.vue delete mode 100644 frontend/src/views/creators/NameEditor.vue delete mode 100644 frontend/src/views/creators/NameTitle.vue delete mode 100644 frontend/src/views/creators/PaymentCompleted.vue delete mode 100644 frontend/src/views/creators/PaymentFailed.vue delete mode 100644 frontend/src/views/documentation/About.vue delete mode 100644 frontend/src/views/documentation/ContentPolicy.vue delete mode 100644 frontend/src/views/documentation/CreatorGuide.vue delete mode 100644 frontend/src/views/documentation/DocumentationLayout.vue delete mode 100644 frontend/src/views/documentation/FAQ.vue delete mode 100644 frontend/src/views/documentation/HelpAndContact.vue delete mode 100644 frontend/src/views/documentation/Pricing.vue delete mode 100644 frontend/src/views/documentation/TermsAndConditions.vue delete mode 100644 frontend/src/views/documentation/documentation.css create mode 100644 frontend/src/views/main/AppBar.vue create mode 100644 frontend/src/views/main/AppSidebar.vue delete mode 100644 frontend/src/views/main/Footer.vue delete mode 100644 frontend/src/views/main/SiteBar.vue delete mode 100644 frontend/src/views/profile/ProfilePage.vue delete mode 100644 frontend/src/views/profile/account/AliasDialog.vue delete mode 100644 frontend/src/views/profile/account/ChangePasswordDialog.vue delete mode 100644 frontend/src/views/profile/account/EmailDialog.vue delete mode 100644 frontend/src/views/profile/account/FullnameDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangeEmailDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangeNameDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangePhoneDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangeSlugDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangeStripeIdDialog.vue delete mode 100644 frontend/src/views/profile/creators/ChangeTitleDialog.vue delete mode 100644 frontend/src/views/profile/creators/SocialsDialog.vue diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6e970fa..3b4b296 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -7,7 +7,7 @@ on: env: AZURE_WEBAPP_NAME: hutopy-backend-api - DOTNET_VERSION: '9.0.x' + DOTNET_VERSION: '10.0.x' jobs: build_and_deploy: @@ -36,4 +36,4 @@ jobs: with: app-name: ${{ env.AZURE_WEBAPP_NAME }} publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - package: './backend/publish/publish/Hutopy/release/' + package: './backend/publish/publish/Socialize.Api/release/' diff --git a/AGENTS.md b/AGENTS.md index 57e7e99..3b01f0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,35 @@ ## Purpose This document is a working guide for coding agents in this repository. It captures the current architecture, conventions, and safe execution workflow for making reliable changes. +## Documentation-First Workflow +Agents must treat repository documentation as the source of truth. Conversation history is secondary and may be incomplete, stale, or contradictory. + +Before making any substantial code change, agents must read the relevant docs first. At minimum, inspect: +- `AGENTS.md` +- `docs/LLM_DEVELOPMENT_WORKFLOW.md` +- `docs/PRODUCT.md` when product behavior, UX, or user workflow may change +- `docs/ARCHITECTURE.md` when structure, module boundaries, routing, data flow, or integration points may change +- `docs/CONVENTIONS.md` when adding or modifying code patterns +- `docs/DECISIONS.md` before revisiting architecture or product decisions +- the active `docs/tasks/TASK-*.md` file when one exists + +If one of these files does not exist yet, do not invent broad behavior from chat history. State what is missing and proceed only with the narrowest safe interpretation of the current task. + +For non-trivial work, follow this sequence: +1. Read the relevant docs and existing code. +2. Restate the task in a short summary. +3. Identify backend impact, frontend impact, data impact, and documentation impact. +4. List files likely to change. +5. Surface ambiguities or risky assumptions. +6. Propose a minimal implementation plan. +7. Implement only the approved or clearly requested scope. +8. Validate with the relevant commands before finishing when possible. + +Do not use a long chat thread as the durable memory for the project. Durable decisions, conventions, and task requirements belong in repository docs. + ## Pair Working Mode - Work as a pair with the repository owner, not as an isolated implementer. -- Before substantial changes, restate the task briefly and inspect the existing code or docs first. +- Before substantial changes, read the relevant docs first, then restate the task briefly and inspect the existing code. - Surface assumptions, tradeoffs, and blockers early instead of silently picking risky directions. - Prefer small, reviewable increments when the product direction is still being shaped. - When requirements are exploratory, help turn them into concrete workflows, domain language, and next implementation steps. @@ -14,29 +40,34 @@ This document is a working guide for coding agents in this repository. It captur - When creating commits, use the Conventional Commits format, for example `docs: update product planning`. ## Repository Layout -- `backend/`: ASP.NET Core (`net9.0`) API using FastEndpoints, EF Core (PostgreSQL), Stripe, Azure Blob Storage, and ASP.NET Identity. +- `backend/`: ASP.NET Core (`net10.0`) API using FastEndpoints, EF Core (PostgreSQL), ASP.NET Identity, and modular bounded contexts for workflow data. - `frontend/`: Vue 3 + Vite + Vuetify + Pinia + Vue Router + Tailwind CSS SPA. -- `.github/workflows/`: deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps). +- `.github/workflows/`: build/deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps). ## Local Runbook ### Backend -- Prereqs: .NET 9 SDK, Docker, PostgreSQL container. +- Prereqs: .NET 10 SDK, Docker, PostgreSQL container. - Start database: - `cd backend` - `./scripts/start-infrastructure.sh` - Run API: - - `dotnet run` (from `backend/`) + - `dotnet run --project Socialize.Api.csproj` (from `backend/`) +- Local API URL: + - `http://localhost:5000` - Swagger/OpenAPI UI in dev: - `/api` ### Frontend -- Prereqs: Node/npm, local HTTPS cert files expected by Vite: - - `frontend/localhost-key.pem` - - `frontend/localhost.pem` +- Prereqs: Node/npm. +- Runtime configuration: + - frontend app config is loaded from `.env.development` and `.env.production` + - `frontend/src/config.js` is the single frontend source of truth for runtime env access - Commands: - `cd frontend && npm install` - `npm run dev` - `npm run build` +- Local dev server: + - `http://localhost:5173` ## Backend Architecture ### Composition Root @@ -44,7 +75,7 @@ This document is a working guide for coding agents in this repository. It captur - Registers: - Web services/auth (`backend/DependencyInjection.cs`) - Infrastructure services (`backend/Infrastructure/DependencyInjection.cs`) - - Modules: Identity, Creators, Contents, Memberships, Tipping, Messaging. + - Modules: Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications. - Each module has: - `Add{Module}Module(...)` to register DbContext/services. - `Use{Module}ModuleAsync()` to auto-run migrations at startup. @@ -57,78 +88,102 @@ This document is a working guide for coding agents in this repository. It captur ### Data Boundaries - Separate DbContext per module: - - Identity, Creators, Contents, Memberships, Tipping, Messaging. + - Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications. - Migrations are module-scoped under each `Modules/*/Migrations` folder. ### Auth/Security - JWT is generated manually in `Infrastructure/Security/GenerateJwtToken.cs`. - Refresh-token flow is implemented in Identity handlers (`/api/users/login`, `/api/users/refresh`). - User claim helpers live in `Infrastructure/Security/ClaimsPrincipalExtensions.cs`. +- Role-gated frontend routes currently use `Administrator` and `Manager` checks for settings access. -### Payments/Stripe -- Tip checkout: `Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs`. -- Membership checkout: `Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs`. -- Webhook endpoint: `Modules/Memberships/Handlers/StripeWebhookEndpoint.cs`. -- Creator onboarding/status/revoke Stripe: - - `/api/stripe/connect` - - `/api/stripe/check-status` - - `DELETE /api/stripe` - -### Blob Storage -- `IBlobStorage` implemented by `AzureBlobStorage`. -- Upload size/type checks are enforced there (10 MB max + content-type validation). +### Current Domain Modules +- `Identity`: authentication, refresh tokens, email verification, password reset, social login. +- `Workspaces`: workspace membership, workspace settings, access scoping. +- `Clients`: client records and primary contacts tied to workspaces. +- `Projects`: project pipeline and client/project relationships. +- `ContentItems`: reviewable content records tied to clients/projects. +- `Assets`: linked asset metadata and revision history. +- `Comments`: discussion threads on reviewable work. +- `Approvals`: review decisions and workflow state transitions. +- `Notifications`: activity feed and unread workflow notifications. ## Frontend Architecture ### Bootstrap - `frontend/src/main.js` wires Vue app + Pinia + Vuetify + Router + i18n + Google OAuth + Toasts. +- `frontend/src/config.js` is the app-facing runtime configuration module. Do not scatter `import.meta.env` reads across the app. ### Routing - Defined in `frontend/src/router/router.js`. - Route guards enforce: - `meta.requiresAuth` - `meta.notAuthenticated` -- Creator public route convention: `/@:creator`. + - optional `meta.roles` +- Primary authenticated app routes live under `/app/*`. ### State Management - Pinia stores: - `authStore`: token lifecycle + refresh concurrency guard. + - `workspaceStore`: active workspace context. + - `clientsStore`: client list and creation flows. + - `projectsStore`: project list and creation flows. + - `contentItemsStore` and `contentItemDetailStore`: content item listing/detail flows. + - `reviewQueueStore`: pending review work. + - `notificationsStore`: workflow notifications. - `userProfileStore`: current user profile and account edits. - - `creatorProfileStore`: creator-owned profile actions. - - `brandingStore`: creator page branding fetched from slug route param. ### API Client - Axios client in `frontend/src/plugins/api.js`. - Injects bearer token, proactively refreshes near expiry, retries once on 401. ## High-Value Domains -- Identity and social login (`Modules/Identity/*`, `frontend/src/views/auth/*`). -- Creator public profile and management (`Modules/Creators/*`, `frontend/src/views/creators/*`, `frontend/src/views/profile/*`). -- Monetization: - - Tips (`Modules/Tipping/*`, creator donation UI) - - Memberships (`Modules/Memberships/*`, Stripe webhook orchestration) -- Content albums/photo upload (`Modules/Contents/*`). -- Messaging thread/replies (`Modules/Messaging/*`). +- Identity and social login (`backend/Modules/Identity/*`, `frontend/src/views/auth/*`). +- Workspace-scoped operations and role checks (`backend/Modules/Workspaces/*`, `frontend/src/stores/workspaceStore.js`, `frontend/src/router/router.js`). +- Client and project workflow (`backend/Modules/Clients/*`, `backend/Modules/Projects/*`, `frontend/src/views/app/ClientsView.vue`, `frontend/src/views/app/ProjectsView.vue`). +- Content review lifecycle (`backend/Modules/ContentItems/*`, `backend/Modules/Assets/*`, `backend/Modules/Comments/*`, `backend/Modules/Approvals/*`, `frontend/src/views/app/ContentItemsView.vue`, `frontend/src/views/app/ContentItemDetailView.vue`, `frontend/src/views/app/ReviewQueueView.vue`). +- Notifications and workflow awareness (`backend/Modules/Notifications/*`, `frontend/src/stores/notificationsStore.js`). + +## Task-Driven Development With Agents +Use `docs/tasks/TASK-*.md` files as LLM-friendly implementation tickets. A task file should be self-contained enough for a fresh agent to understand the desired change without relying on a long conversation. + +A good task file defines: +- objective and product context +- scope and out of scope +- backend requirements, API contract, validation, data, authorization +- frontend requirements, route/screen, components, state, API integration, UX states +- files likely involved +- acceptance criteria +- validation plan +- risks and open questions + +Features are fullstack by default unless the task explicitly says otherwise. Do not assume a feature is backend-only. For user-facing work, define both backend and frontend behavior before implementation. + +When an adjacent issue is discovered outside the task scope, do not fix it opportunistically. Report it as a suggested backlog item or add it to `docs/BACKLOG.md` if explicitly asked. ## Agent Working Rules For This Repo 1. Keep module boundaries intact. Do not couple DbContexts across modules. 2. When adding endpoints, follow existing FastEndpoints pattern with validator + explicit route + tag. 3. If schema changes are needed, generate migration in the matching module only. 4. Preserve token refresh behavior in frontend client/store; avoid introducing parallel refresh races. -5. For file uploads, enforce content-type/size limits and reuse blob path conventions. -6. Keep creator route contract stable (`/@slug`) because frontend and backend both depend on it. -7. Do not commit secrets. Existing appsettings include sensitive-looking values; treat as legacy and avoid propagating. +5. Keep frontend runtime configuration centralized in `frontend/src/config.js` and `.env.*`; do not introduce ad hoc env fallbacks. +6. Preserve workspace scoping and route-role checks when editing app flows. +7. Do not commit secrets. Existing appsettings and env files include sensitive-looking values; treat them as legacy and avoid propagating. +8. For non-trivial features, prefer a `docs/tasks/TASK-*.md` file before implementation. +9. Treat frontend behavior as part of the feature definition: route, components, Pinia store usage, API integration, loading/error/success states, and navigation must be explicit or derived from existing patterns. +10. If requirements conflict with repository docs, stop and surface the conflict instead of silently choosing one. ## Validation Checklist Before Finishing - Backend: - - `cd backend && dotnet build` - - run affected endpoint flows if change touches handlers/auth/payments/storage + - `cd backend && dotnet build Socialize.Api.csproj` + - run affected endpoint flows if change touches handlers/auth/workspace scoping/data writes - Frontend: - `cd frontend && npm run build` - - validate affected route/store interactions in browser + - validate affected route/store interactions in browser when UI behavior changed - If migrations were changed: - ensure module context name/output directory remain consistent with `backend/scripts/add-migration.sh`. ## Notes / Known Sharp Edges -- Frontend expects `VITE_API_URL` in API plugin; `src/config.js` uses `VITE_APP_API_URL` naming. Keep env usage consistent when editing. -- `GetReceivedTips` currently resolves tipper with `tip.CreatorId` instead of `tip.CreatedBy`; verify intent before refactoring. +- Frontend config should come through `.env.development` / `.env.production` and `frontend/src/config.js`; avoid direct `import.meta.env` reads in feature code. +- Backend development now runs on HTTP locally (`http://localhost:5000`), while HTTPS redirection stays enabled outside development. +- `frontend/.env.development` is currently checked in and points `VITE_API_URL` to `http://192.168.1.2:5000`; verify whether changes should target `localhost` or the LAN host before editing. - Some style/formatting is inconsistent across JS/Vue/C# files; minimize churn to touched lines. diff --git a/README.md b/README.md index fcf7464..482002e 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,88 @@ -# Hutopy +# Socialize -## Patterns / strategy used -- Clean Architecture ( with Infrastructure, Domain, Application and Web layers ) -- Minimal API endpoints. +Socialize is a workflow application for social media content review, revision, approval, and publication readiness. -## Tools -- Install Docker : https://www.docker.com/get-started/ -- Install sql server management ( or preferred tool ) : https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16#download-ssms +It is not a public social network. The current product direction is a workspace-based review tool for internal teams, providers, and client approvers. -## Database setup in docker for local dev -``` -docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=P@ssword123!" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest -``` +## Repository Structure -Or with a mounted volume to persist data on the computer instead ( persist data even if the container is deleted ) -``` -docker run -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=P@ssword123!' -p 1433:1433 -v C:\dev\DockerVolumes\SqlServer-Utopy-1\data:/var/opt/mssql/data -v C:\dev\DockerVolumes\SqlServer-Utopy-1\log:/var/opt/mssql/log -v C:\dev\DockerVolumes\SqlServer-Utopy-1\secrets:/var/opt/mssql/secrets -d mcr.microsoft.com/mssql/server:2022-latest -``` +- `backend/`: ASP.NET Core `net10.0` API with FastEndpoints, EF Core, PostgreSQL, and modular bounded contexts. +- `frontend/`: Vue 3 + Vite + Vuetify + Pinia SPA. +- `docs/`: product, planning, and archived project documentation. -## Postgres DB setup in docker for local dev -``` -docker run -p 5432:5432 --name Hutopy -e POSTGRES_PASSWORD=P@ssword123! -e POSTGRES_USER=sa -d postgres +## Current Backend Modules +- `Identity` +- `Workspaces` +- `Clients` +- `Projects` +- `ContentItems` +- `Assets` +- `Comments` +- `Approvals` +- `Notifications` -``` +## Local Development -## Entity Framework +### Backend -Create a new migration : -``` -./Ef.ps1 migrations add NomDeLaMigration -``` +Prerequisites: -Update database : -``` -./Ef.ps1 database update -``` +- .NET 10 SDK +- Docker -## Secret Manager tool -Go to Web project: cd src/Web - -Add a user secret for local development : -``` -dotnet user-secrets set "DB_PASSWORD" "12345" -``` - -list your stored secrets : -``` -dotnet user-secrets list -``` - -Delete a secret : -``` -dotnet user-secrets remove "DB_PASSWORD" -``` - -## Build - -Run `dotnet build -tl` to build the solution. - -## Run - -To run the web application: +Start infrastructure: ```bash -cd .\src\Web\ -dotnet watch run +cd backend +./scripts/start-infrastructure.sh ``` -Navigate to https://localhost:5001. The application will automatically reload if you change any of the source files. - -## Code Styles & Formatting - -The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution. - -## Code Scaffolding - -Scaffold new commands and queries. - -Start in the `.\src\Application\` folder. - -Create a new command: - -``` -dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int -``` - -Create a new query: - -``` -dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm -``` - -If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again: +Run the API: ```bash -dotnet new install Clean.Architecture.Solution.Template::8.0.4 +cd backend +dotnet run --project Socialize.Api.csproj ``` -## Test +Local backend URL: -The solution contains unit, integration, and functional tests. +- `http://localhost:5000` +- Swagger UI: `http://localhost:5000/api` -- Using Moq, Nunit, Respawn, FluentAssertions +### Frontend + +Prerequisites: + +- Node.js / npm + +The frontend reads runtime values from: + +- `frontend/.env.development` +- `frontend/.env.production` +- `frontend/src/config.js` + +Run the frontend: -To run the tests: ```bash -dotnet test -``` \ No newline at end of file +cd frontend +npm install +npm run dev +``` + +Local frontend URL: + +- `http://localhost:5173` + +## Validation + +- Backend: `cd backend && dotnet build Socialize.Api.csproj` +- Frontend: `cd frontend && npm run build` + +## Docs + +- [docs/README.md](/home/jbourdon/repos/social-media/docs/README.md) +- [docs/product/vision.md](/home/jbourdon/repos/social-media/docs/product/vision.md) +- [docs/product/glossary.md](/home/jbourdon/repos/social-media/docs/product/glossary.md) +- [docs/constraints.md](/home/jbourdon/repos/social-media/docs/constraints.md) +- [AGENTS.md](/home/jbourdon/repos/social-media/AGENTS.md) diff --git a/SOCIALIZE.fr.md b/SOCIALIZE.fr.md deleted file mode 100644 index 68d7745..0000000 --- a/SOCIALIZE.fr.md +++ /dev/null @@ -1,328 +0,0 @@ -# Flux d'approbation pour les contenus de medias sociaux - -Nom temporaire du produit : `Socialize` - -## Intention du projet - -Construire `Socialize`, une application qui remplace le processus actuel d'approbation base sur Google Drive, les appels telephoniques, les courriels et les feuilles de calcul. - -Le produit n'est pas un reseau social public. C'est un outil de flux de travail interne/externe pour la revision de contenu, la collecte de commentaires, l'approbation et la preparation a la publication. - -## Vocabulaire partage - -- Flux d'approbation : le processus complet entre la creation d'un brouillon et l'approbation finale. -- Element de contenu : l'unite a reviser qui regroupe les fichiers, le message de publication ou le texte, les dates et les canaux cibles. -- Ressource : un fichier rattache a un element de contenu, par exemple une video, une image ou un document. -- Revision : une nouvelle version d'une ressource ou d'un texte apres commentaires. -- Reviseur externe : un client ou un partenaire qui revise du contenu sans faire partie de l'equipe interne. -- Fournisseur : un partenaire de production externe, par exemple une equipe video, un photographe, un monteur ou un designer, qui peut livrer des brouillons et recevoir des demandes de modifications. -- Software as a Service (SaaS) / logiciel en tant que service : un produit en ligne utilise via le web, comme Canva, MailChimp, HootSuite ou Metricool. -- Minimum Viable Product (MVP) / produit minimum viable : la plus petite version du produit qui regle suffisamment bien le probleme principal pour valider le marche. -- Service Level Agreement (SLA) / accord de niveau de service : une cible de service convenue, par exemple une date limite de revision ou un seuil d'escalade. - -## Enonce du probleme - -Les gestionnaires de medias sociaux et les equipes de production gerent actuellement les approbations de contenu de facon manuelle : - -- Les ressources sont stockees dans Google Drive. -- Le gestionnaire de medias sociaux fait souvent des allers-retours autant avec les fournisseurs qu'avec les clients. -- Les commentaires circulent par telephone, courriel, message et feuille de calcul. -- L'historique des versions est flou. -- Il est difficile de savoir quel fichier est le plus recent. -- Les commentaires sont disperses sur plusieurs canaux. -- Les approbations internes et celles des clients suivent des logiques semblables mais ne sont pas centralisees. -- Les suivis sont manuels, ce qui retarde les approbations. - -Resultat : trop d'allers-retours, peu de tracabilite, des delais evitables et un risque de publier le mauvais fichier ou une version de texte perimee. - -## Outils observes actuellement - -- Google Drive pour les videos, images, calendriers et documents -- Google Sheets ou equivalent pour suivre les commentaires et les statuts -- Telephone et courriel pour les conversations de revision et d'approbation -- HootSuite -- Metricool -- Canva -- MailChimp - -## Utilisateurs principaux - -- Gestionnaire de medias sociaux -- Gestionnaire de compte / service client -- Approbateur cote client -- Fournisseur externe / partenaire de production -- Producteur interne -- Employe interne / contributeur au contenu -- Administrateur - -## Cas d'utilisation principaux - -### 1. Flux d'approbation client - -Un gestionnaire de medias sociaux prepare du contenu pour un client et le soumet pour approbation. - -Le client doit pouvoir : - -- consulter le lot de contenu -- previsualiser les fichiers -- lire les legendes, descriptions et notes de projet -- laisser des commentaires -- demander des modifications -- approuver ou rejeter - -L'equipe doit pouvoir : - -- voir le statut d'approbation en temps reel -- repondre aux commentaires dans leur contexte -- televerser des versions revisees -- conserver une piste d'audit claire indiquant qui a dit quoi et quand -- savoir exactement quelle version est approuvee - -### 2. Flux de production interne - -Le meme flux doit fonctionner a l'interne pour les producteurs, employes et partenaires de production externes avant que le contenu soit montre au client ou planifie pour publication. - -Exemple : - -- un contributeur televerse un brouillon -- un fournisseur externe peut televerser un brouillon ou une version revisee -- un producteur revise et demande des modifications -- un gestionnaire approuve pour la revision client -- le client approuve -- le contenu est marque pret pour la publication - -### 3. Revision d'un lot de contenu - -L'approbation ne doit pas se limiter a un seul fichier. Un element a reviser peut inclure : - -- video -- image -- document -- message de publication / legende / texte -- mots-clics -- liens -- dates de publication -- canaux cibles ou reseaux sociaux - -## Resume du flux actuel - -Flux actuel typique : - -1. L'equipe cree les ressources media. -2. Les fichiers sont places dans Google Drive par l'equipe ou par des fournisseurs externes. -3. Un gestionnaire envoie les liens par courriel ou message aux fournisseurs, aux intervenants internes ou aux clients. -4. Les commentaires reviennent par telephone, courriel, feuille de calcul ou clavardage. -5. L'equipe consolide manuellement les commentaires provenant des fournisseurs et des clients. -6. Une version revisee est televersee. -7. Le cycle se repete jusqu'a ce que quelqu'un dise que c'est approuve. -8. Le statut d'approbation est suivi manuellement ailleurs. - -Principaux points d'echec : - -- aucune source de verite unique -- aucun etat d'approbation structure -- aucun fil de commentaires centralise -- aucun rappel d'echeance -- aucune piste d'audit fiable -- aucune barriere d'approbation avant publication - -## Flux cible - -1. Creer un projet et l'associer a un client. -2. Creer un element de revision ou une demande d'approbation. -3. Joindre des ressources ou les importer depuis Google Drive. -4. Ajouter des metadonnees : - - titre - - message de publication / legende / texte - - plateforme cible ou reseau social - - dates de publication par reseau lorsque pertinent - - date d'echeance - - reviseur(s) -5. Envoyer la demande de revision. -6. Les reviseurs commentent directement sur l'element. -7. L'equipe ou le fournisseur televerse une revision ou repond aux commentaires. -8. Le systeme suit les versions, les changements de statut et les evenements du flux. -9. Le reviseur approuve, rejette ou demande des modifications. -10. Une fois toutes les approbations requises obtenues, l'element devient pret pour la planification ou la publication. - -## Objets de domaine principaux - -- Espace de travail : la frontiere principale du compte pour une agence ou une equipe operationnelle. -- Client : l'entreprise, le createur ou la marque qui recoit le service et approuve le contenu. -- Membre d'equipe : un utilisateur interne qui travaille sur le contenu, les revisions ou la coordination. -- Reviseur : toute personne a qui l'on demande de reviser et d'approuver, qu'elle soit interne ou externe. -- Fournisseur : un contributeur de production externe, comme un photographe, videaste, monteur ou designer. -- Projet : le principal conteneur de travail pour un client, qui regroupe des elements de contenu, des notes, des participants et des echeances. -- Element de contenu : l'unite a reviser qui contient les ressources, le message de publication, les canaux cibles, les dates d'echeance et l'etat d'approbation. -- Ressource : un fichier joint, comme une video, une image ou un document, reference depuis Google Drive ou stocke directement. -- Version de ressource : une revision precise d'une ressource, avec tracabilite de la personne qui l'a televersee et du moment. -- Fil de commentaires : une discussion contextuelle rattachee a un element de contenu, une ressource ou une revision. -- Demande d'approbation : l'action de demander a un ou plusieurs reviseurs de reviser une version precise. -- Decision d'approbation : le resultat d'une demande de revision, par exemple approuve, rejete ou modifications demandees. -- Historique des statuts : la piste d'audit des etats et transitions du flux dans le temps. -- Cible de publication : la destination prevue pour la publication, par exemple Instagram, Facebook, LinkedIn ou une infolettre. -- Evenement de notification : un evenement du flux qui informe les utilisateurs qu'un commentaire, une revision, une demande ou une approbation vient d'avoir lieu. - -## Modele de statuts suggere - -- Brouillon -- En revision interne -- Modifications demandees a l'interne -- Modifications internes en cours -- Pret pour revision client -- En revision client -- Modifications demandees par le client -- Modifications client en cours -- Approuve -- Rejete -- Pret a publier -- Publie -- Archive - -## Portee du Minimum Viable Product (MVP) / produit minimum viable - -La premiere version doit se concentrer sur le flux d'approbation, et non sur la publication directe. - -### Fonctionnalites MVP - -- authentification et roles utilisateurs -- structure espace de travail / client / projet -- creation d'un element de contenu avec metadonnees -- televersement de ressources ou ajout de liens Google Drive tout en gardant Google Drive comme source de verite lorsque le client l'exige -- suivi des versions pour les fichiers et les textes -- commentaires centralises -- decisions d'approbation : approuver, rejeter, demander des modifications -- chronologie d'activite / piste d'audit -- tableau de bord par client, projet et date d'echeance -- notifications et rappels lorsque des actions sont completees ou que des evenements du flux surviennent -- portail simple d'approbation pour les clients externes - -### Fonctionnalites candidates fortes pour le MVP - -- approbateurs obligatoires -- date limite d'approbation -- dates d'echeance par cible de publication ou reseau social -- comparaison entre version courante et version precedente -- indicateur de la "derniere version approuvee" -- resolution des commentaires -- filtres par statut, client, responsable et date d'echeance - -## Possibilites pour la phase 2 - -- integration Google Drive avec synchronisation ou import de fichiers -- export ou transfert vers HootSuite / Metricool -- liaison avec les ressources Canva -- flux d'approbation MailChimp pour les infolettres -- integration calendrier pour la visibilite sur la planification des publications -- commentaires annotes sur images ou sur horodatages video -- modeles de flux d'approbation reutilisables par type de contenu -- rappels et escalades bases sur les Service Level Agreements (SLA) / accords de niveau de service -- analyses sur les temps de traitement et les goulots d'etranglement -- approbation par lien recu par courriel -- regles d'approbation a plusieurs etapes selon le client - -## Possibilites d'automatisation importantes - -- demander automatiquement une approbation lorsqu'un element atteint une etape definie -- envoyer automatiquement des notifications lorsqu'une action est completee ou qu'un evenement du flux survient -- envoyer automatiquement des rappels avant les echeances -- escalader automatiquement lorsqu'une approbation est en retard -- etiqueter automatiquement les versions -- passer automatiquement a l'etat "pret a publier" lorsque toutes les approbations sont completees -- conserver automatiquement une piste d'audit de chaque televersement, commentaire et decision -- generer automatiquement un lien de revision cote client -- notifier automatiquement lorsqu'une nouvelle revision repond aux modifications demandees - -## Decisions produit importantes - -### 1. Systeme de reference pour les ressources - -Options : - -- garder Google Drive comme stockage de fichiers et construire le flux autour -- televerser les fichiers directement dans la nouvelle application -- supporter les deux - -Premiere hypothese recommandee : - -Garder Google Drive comme source de verite lorsque le client exige d'en conserver la propriete, et supporter plus tard les televersements directs comme option. La premiere version doit fonctionner proprement avec les liens Drive et les metadonnees importees avant d'envisager une synchronisation plus poussee. - -### 2. Experience du reviseur externe - -Options : - -- compte reviseur obligatoire -- acces par lien magique sans compte complet -- les deux - -Premiere hypothese recommandee : - -Utiliser l'acces par lien magique pour les clients afin de reduire la friction. - -### 3. Granularite de l'approbation - -Unites d'approbation possibles : - -- element de contenu complet -- par ressource -- par legende / texte -- par variation de canal - -Premiere hypothese recommandee : - -Approuver au niveau de l'element de contenu dans le Minimum Viable Product (MVP), avec des commentaires rattaches aux ressources et au texte. - -## Regles d'affaires a confirmer - -Ces points ne bloquent pas le cadrage initial, mais il faut les documenter tot pour que le comportement du produit corresponde bien au vrai processus d'approbation. - -- Un client peut-il approuver s'il reste des commentaires non resolus ? -- L'approbation exige-t-elle un seul reviseur ou plusieurs reviseurs ? -- L'approbation interne et l'approbation client peuvent-elles se faire en parallele ? -- L'approbation est-elle valide seulement pour la version la plus recente ? -- Un element approuve peut-il etre modifie sans rouvrir la revision ? -- Des clients differents ont-ils besoin de flux differents ? -- Les videos, images et documents sont-ils tous aussi importants des le jour 1 ? -- La planification ou publication fait-elle partie de la portee, ou seulement le passage a l'etat "pret a publier" ? - -## Questions ouvertes pour la prochaine entrevue - -- Qui est l'acheteur : agence, travailleur autonome ou equipe marketing interne ? -- Le premier marche cible est-il l'approbation agence-client, l'approbation interne ou les deux ? -- Quels types de contenu sont prioritaires : video, image, documents, legende, infolettres ? -- A quelle frequence les clients demandent-ils des modifications apres une approbation verbale ? -- Quelle est aujourd'hui l'etape la plus douloureuse ? -- Quels outils doivent absolument rester en place au lancement ? -- Quelles approbations exigent une tracabilite legale ou de conformite ? -- Combien de reviseurs participent habituellement a chaque element ? -- Le bilinguisme est-il requis ? -- La revision mobile est-elle importante au jour 1 ? - -## Criteres de succes du Minimum Viable Product (MVP) / produit minimum viable - -- reduire le temps necessaire pour obtenir une approbation -- reduire les allers-retours entre courriels, telephone et feuilles de calcul -- fournir une source de verite claire pour la derniere version et le statut courant -- permettre a un client d'approuver sans formation -- permettre a l'equipe de voir instantanement les elements bloques - -## Positionnement du produit - -Ce produit devrait etre positionne comme suit : - -"Un flux de revision et d'approbation pour le contenu de medias sociaux, et non un autre outil de creation de contenu." - -La valeur se trouve dans la coordination, la tracabilite et l'acceleration des cycles d'approbation. - -## Recommandation pour la premiere version - -Construire la premiere version autour de ce flux etroit : - -1. l'equipe cree un element de contenu -2. l'equipe televerse les fichiers et le texte -3. un reviseur interne commente et demande des modifications -4. l'equipe soumet l'element au client -5. le client commente et approuve via un lien simple -6. l'element devient pret pour le transfert vers la publication - -Si ce flux fonctionne proprement, les integrations et la planification pourront etre ajoutees ensuite. diff --git a/TEMPLATE_PROMPT.md b/TEMPLATE_PROMPT.md new file mode 100644 index 0000000..b169528 --- /dev/null +++ b/TEMPLATE_PROMPT.md @@ -0,0 +1,327 @@ +# PROMPT TEMPLATES + +## Purpose +This document standardizes how we interact with AI coding agents (Codex, Claude, etc). + +Goals: +- Reduce prompt variability +- Prevent architectural drift +- Improve consistency and reliability +- Enable repeatable workflows + +--- + +# 🧠 Core Principle + +The AI is NOT the source of truth. + +The source of truth is: +- docs/PRODUCT.md +- docs/ARCHITECTURE.md +- docs/CONVENTIONS.md +- docs/DECISIONS.md +- docs/tasks/*.md + +All prompts MUST reference these. + +--- + +# 🔁 Standard Workflow + +1. PLAN +2. BREAKDOWN (optional) +3. IMPLEMENT (step-by-step) +4. REVIEW + +Never skip directly to implementation for non-trivial features. + +--- + +# 🧩 Prompt Templates + +--- + +## 1. PLAN (default starting point) + +### When to use +- New feature +- Complex change +- Refactor +- Anything unclear + +### Prompt + +You are working inside an existing repository. + +Before doing anything: +1. Read: + - AGENTS.md + - docs/PRODUCT.md + - docs/ARCHITECTURE.md + - docs/CONVENTIONS.md + - docs/DECISIONS.md +2. Read the task: + - docs/tasks/TASK-XXX.md + +Do NOT modify code. + +Output: +1. Summary (<=10 lines) +2. Relevant architecture +3. Files likely involved +4. Implementation plan +5. Risks / ambiguities + +--- + +## 2. BREAKDOWN + +### When to use +- Task is too large +- You want step-by-step execution + +### Prompt + +Break this task into atomic steps. + +For each step: +- goal +- files involved +- dependencies +- risks + +Constraints: +- 3–7 steps max +- each step must be independently implementable +- keep changes small + +--- + +## 3. IMPLEMENT + +### When to use +- After plan is validated + +### Prompt + +Implement ONLY the agreed plan. + +Read first: +- AGENTS.md +- docs/* +- docs/tasks/TASK-XXX.md + +Rules: +- Minimal diff +- No refactor outside scope +- No new libraries +- Respect architecture and conventions + +At the end: +1. Modified files +2. Summary of changes +3. Commands to test +4. Remaining risks + +--- + +## 4. STEP IMPLEMENTATION + +### When to use +- When using breakdown approach + +### Prompt + +Implement ONLY step X. + +Do not touch anything outside this step. + +Stop after completion. + +--- + +## 5. REVIEW + +### When to use +- Before commit +- After major change + +### Prompt + +Review the implementation against: + +- docs/tasks/TASK-XXX.md +- docs/ARCHITECTURE.md +- docs/CONVENTIONS.md + +Check: +- acceptance criteria +- architecture violations +- regression risks +- missing edge cases + +Output: +1. Issues +2. Fix suggestions +3. Risk level +4. Ready to commit? (yes/no) + +--- + +## 6. ANALYSIS (no code) + +### When to use +- Debugging +- Understanding codebase +- Investigating issues + +### Prompt + +Do NOT modify code. + +Analyze: +- architecture consistency +- state management +- API usage +- potential bugs + +Output: +- what is correct +- what is fragile +- what should be improved + +--- + +## 7. TASK GENERATION + +### When to use +- Turning feature idea into executable task + +### Prompt + +Generate a TASK.md file. + +Include: +- Objective +- Scope +- Out of scope +- Backend section +- Frontend section +- API contract +- Files involved +- Acceptance criteria +- Edge cases +- Constraints + +Must be: +- clear +- complete +- self-contained + +--- + +## 8. STRICT MODE + +### When to use +- Agent starts drifting +- Too many unexpected changes + +### Prompt + +STRICT MODE: + +- No assumptions +- No extra features +- No refactoring +- No architecture changes + +Do ONLY what is defined. + +If unclear → stop and ask. + +--- + +## 9. ANTI-HALLUCINATION + +### When to use +- Missing info +- Unclear requirements + +### Prompt + +If information is missing: +- do NOT assume +- do NOT invent + +Instead: +- list missing info +- propose options + +Wait for clarification. + +--- + +## 10. STACK-SPECIFIC (Vue + .NET) + +### When to use +- Reinforce stack constraints + +### Prompt + +You are working on: + +Frontend: +- Vue 3 +- Pinia +- Tailwind + +Backend: +- .NET FastEndpoints +- Modular DbContexts + +Rules: + +Backend: +- follow FastEndpoints pattern +- no cross-module DbContext coupling + +Frontend: +- use Pinia for state +- no business logic in components +- use API client + +Always align with docs. + +--- + +# 🚨 Rules + +- Never start coding without reading docs +- Never trust conversation history alone +- Always constrain scope +- Always review before commit + +--- + +# 🧭 Summary + +Bad: +"Add profile feature" + +Good: +- PLAN +- IMPLEMENT step 1 +- REVIEW +- repeat + +--- + +# 🔥 Recommended Usage with CLI + +scripts/ai-task plan docs/tasks/TASK-XXX.md +scripts/ai-task implement docs/tasks/TASK-XXX.md +scripts/ai-task review docs/tasks/TASK-XXX.md + +--- + +End of document. \ No newline at end of file diff --git a/backend/Common/Domain/Entity.cs b/backend/Common/Domain/Entity.cs index 071cfca..b3a0015 100644 --- a/backend/Common/Domain/Entity.cs +++ b/backend/Common/Domain/Entity.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Common.Domain; +namespace Socialize.Common.Domain; public abstract class Entity { diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs new file mode 100644 index 0000000..c1088c4 --- /dev/null +++ b/backend/Data/AppDbContext.cs @@ -0,0 +1,226 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Socialize.Modules.Approvals.Data; +using Socialize.Modules.Assets.Data; +using Socialize.Modules.Clients.Data; +using Socialize.Modules.Comments.Data; +using Socialize.Modules.ContentItems.Data; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Notifications.Data; +using Socialize.Modules.Projects.Data; +using Socialize.Modules.Workspaces.Data; + +namespace Socialize.Data; + +public class AppDbContext( + DbContextOptions options) + : IdentityDbContext(options) +{ + public DbSet Workspaces => Set(); + public DbSet WorkspaceInvites => Set(); + public DbSet Clients => Set(); + public DbSet Projects => Set(); + public DbSet ContentItems => Set(); + public DbSet ContentItemRevisions => Set(); + public DbSet Assets => Set(); + public DbSet AssetRevisions => Set(); + public DbSet Comments => Set(); + public DbSet ApprovalRequests => Set(); + public DbSet ApprovalDecisions => Set(); + public DbSet NotificationEvents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(workspace => + { + workspace.ToTable("Workspaces"); + workspace.HasKey(x => x.Id); + workspace.Property(x => x.Name).HasMaxLength(256).IsRequired(); + workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired(); + workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired(); + workspace.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + workspace.HasIndex(x => x.Slug).IsUnique(); + workspace.HasIndex(x => x.OwnerUserId); + }); + + modelBuilder.Entity(workspaceInvite => + { + workspaceInvite.ToTable("WorkspaceInvites"); + workspaceInvite.HasKey(x => x.Id); + workspaceInvite.Property(x => x.Email).HasMaxLength(256).IsRequired(); + workspaceInvite.Property(x => x.Role).HasMaxLength(64).IsRequired(); + workspaceInvite.Property(x => x.Status).HasMaxLength(64).IsRequired(); + workspaceInvite.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + workspaceInvite.HasIndex(x => x.WorkspaceId); + workspaceInvite.HasIndex(x => new { x.WorkspaceId, x.Email, x.Status }); + }); + + modelBuilder.Entity(client => + { + client.ToTable("Clients"); + client.HasKey(x => x.Id); + client.Property(x => x.Name).HasMaxLength(256).IsRequired(); + client.Property(x => x.Status).HasMaxLength(64).IsRequired(); + client.Property(x => x.PortraitUrl).HasMaxLength(2048); + client.Property(x => x.PrimaryContactName).HasMaxLength(256); + client.Property(x => x.PrimaryContactEmail).HasMaxLength(256); + client.Property(x => x.PrimaryContactPortraitUrl).HasMaxLength(2048); + client.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + client.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique(); + client.HasIndex(x => x.WorkspaceId); + }); + + modelBuilder.Entity(project => + { + project.ToTable("Projects"); + project.HasKey(x => x.Id); + project.Property(x => x.Name).HasMaxLength(256).IsRequired(); + project.Property(x => x.Description).HasMaxLength(4000); + project.Property(x => x.Notes).HasMaxLength(4000); + project.Property(x => x.Status).HasMaxLength(64).IsRequired(); + project.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique(); + project.HasIndex(x => x.WorkspaceId); + project.HasIndex(x => x.ClientId); + }); + + modelBuilder.Entity(contentItem => + { + contentItem.ToTable("ContentItems"); + contentItem.HasKey(x => x.Id); + contentItem.Property(x => x.Title).HasMaxLength(256).IsRequired(); + contentItem.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired(); + contentItem.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired(); + contentItem.Property(x => x.Hashtags).HasMaxLength(1024); + contentItem.Property(x => x.Status).HasMaxLength(64).IsRequired(); + contentItem.Property(x => x.CurrentRevisionLabel).HasMaxLength(32).IsRequired(); + contentItem.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + contentItem.HasIndex(x => x.WorkspaceId); + contentItem.HasIndex(x => x.ClientId); + contentItem.HasIndex(x => x.ProjectId); + }); + + modelBuilder.Entity(revision => + { + revision.ToTable("ContentItemRevisions"); + revision.HasKey(x => x.Id); + revision.Property(x => x.RevisionLabel).HasMaxLength(32).IsRequired(); + revision.Property(x => x.Title).HasMaxLength(256).IsRequired(); + revision.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired(); + revision.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired(); + revision.Property(x => x.Hashtags).HasMaxLength(1024); + revision.Property(x => x.ChangeSummary).HasMaxLength(1024); + revision.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + revision.HasIndex(x => x.ContentItemId); + revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique(); + }); + + modelBuilder.Entity(asset => + { + asset.ToTable("Assets"); + asset.HasKey(x => x.Id); + asset.Property(x => x.AssetType).HasMaxLength(64).IsRequired(); + asset.Property(x => x.SourceType).HasMaxLength(64).IsRequired(); + asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired(); + asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256); + asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048); + asset.Property(x => x.PreviewUrl).HasMaxLength(2048); + asset.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + asset.HasIndex(x => x.WorkspaceId); + asset.HasIndex(x => x.ContentItemId); + }); + + modelBuilder.Entity(revision => + { + revision.ToTable("AssetRevisions"); + revision.HasKey(x => x.Id); + revision.Property(x => x.SourceReference).HasMaxLength(2048).IsRequired(); + revision.Property(x => x.PreviewUrl).HasMaxLength(2048); + revision.Property(x => x.Notes).HasMaxLength(1024); + revision.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + revision.HasIndex(x => x.AssetId); + revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique(); + }); + + modelBuilder.Entity(comment => + { + comment.ToTable("Comments"); + comment.HasKey(x => x.Id); + comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired(); + comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired(); + comment.Property(x => x.Body).HasMaxLength(4000).IsRequired(); + comment.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + comment.HasIndex(x => x.WorkspaceId); + comment.HasIndex(x => x.ContentItemId); + comment.HasIndex(x => x.ParentCommentId); + }); + + modelBuilder.Entity(approvalRequest => + { + approvalRequest.ToTable("ApprovalRequests"); + approvalRequest.HasKey(x => x.Id); + approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired(); + approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired(); + approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired(); + approvalRequest.Property(x => x.State).HasMaxLength(64).IsRequired(); + approvalRequest.Property(x => x.AccessToken).HasMaxLength(64).IsRequired(); + approvalRequest.Property(x => x.SentAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + approvalRequest.HasIndex(x => x.WorkspaceId); + approvalRequest.HasIndex(x => x.ContentItemId); + approvalRequest.HasIndex(x => x.ReviewerEmail); + }); + + modelBuilder.Entity(approvalDecision => + { + approvalDecision.ToTable("ApprovalDecisions"); + approvalDecision.HasKey(x => x.Id); + approvalDecision.Property(x => x.Decision).HasMaxLength(64).IsRequired(); + approvalDecision.Property(x => x.Comment).HasMaxLength(2048); + approvalDecision.Property(x => x.DecidedByName).HasMaxLength(256).IsRequired(); + approvalDecision.Property(x => x.DecidedByEmail).HasMaxLength(256).IsRequired(); + approvalDecision.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + approvalDecision.HasIndex(x => x.ApprovalRequestId); + }); + + modelBuilder.Entity(notificationEvent => + { + notificationEvent.ToTable("NotificationEvents"); + notificationEvent.HasKey(x => x.Id); + notificationEvent.Property(x => x.EventType).HasMaxLength(128).IsRequired(); + notificationEvent.Property(x => x.EntityType).HasMaxLength(128).IsRequired(); + notificationEvent.Property(x => x.Message).HasMaxLength(1024).IsRequired(); + notificationEvent.Property(x => x.RecipientEmail).HasMaxLength(256); + notificationEvent.Property(x => x.MetadataJson).HasMaxLength(4000); + notificationEvent.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + notificationEvent.HasIndex(x => x.WorkspaceId); + notificationEvent.HasIndex(x => x.ContentItemId); + notificationEvent.HasIndex(x => x.RecipientUserId); + notificationEvent.HasIndex(x => x.CreatedAt); + }); + } +} diff --git a/backend/DependencyInjection.cs b/backend/DependencyInjection.cs index b77ffae..893bde2 100644 --- a/backend/DependencyInjection.cs +++ b/backend/DependencyInjection.cs @@ -1,5 +1,6 @@ using System.Text; -using Hutopy.Modules.Identity.Data; +using Socialize.Data; +using Socialize.Infrastructure.Security; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Authentication.Google; @@ -7,7 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; -namespace Hutopy; +namespace Socialize; public static class DependencyInjection { @@ -18,9 +19,10 @@ public static class DependencyInjection services.AddHttpContextAccessor(); services.AddHealthChecks() - .AddDbContextCheck(); + .AddDbContextCheck(); services.AddHttpClient(); + services.AddScoped(); // Customise default API behaviour services.Configure(options => @@ -31,6 +33,27 @@ public static class DependencyInjection return services; } + public static IServiceCollection AddAppData( + this IServiceCollection services, + string postgresConnectionString) + { + services.AddDbContext(options => + options.UseNpgsql(postgresConnectionString)); + + return services; + } + + public static async Task UseAppDataAsync( + this IApplicationBuilder app, + CancellationToken cancellationToken = default) + { + using IServiceScope scope = app.ApplicationServices.CreateScope(); + await using AppDbContext context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(cancellationToken); + + return app; + } + public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services, ConfigurationManager configuration) { diff --git a/backend/GlobalUsings.cs b/backend/GlobalUsings.cs index 4b94f25..7dce9da 100644 --- a/backend/GlobalUsings.cs +++ b/backend/GlobalUsings.cs @@ -1,4 +1,14 @@ -global using FastEndpoints; global using FluentValidation; +global using FastEndpoints; global using JetBrains.Annotations; global using Microsoft.EntityFrameworkCore; +global using Socialize.Data; +global using Socialize.Modules.Approvals.Data; +global using Socialize.Modules.Assets.Data; +global using Socialize.Modules.Clients.Data; +global using Socialize.Modules.Comments.Data; +global using Socialize.Modules.ContentItems.Data; +global using Socialize.Modules.Identity.Data; +global using Socialize.Modules.Notifications.Data; +global using Socialize.Modules.Projects.Data; +global using Socialize.Modules.Workspaces.Data; diff --git a/backend/Infrastructure/BlobStorage/Contracts/CommonFileNames.cs b/backend/Infrastructure/BlobStorage/Contracts/CommonFileNames.cs index 4ee35f9..8dc63ce 100644 --- a/backend/Infrastructure/BlobStorage/Contracts/CommonFileNames.cs +++ b/backend/Infrastructure/BlobStorage/Contracts/CommonFileNames.cs @@ -1,7 +1,8 @@ -namespace Hutopy.Infrastructure.BlobStorage.Contracts; +namespace Socialize.Infrastructure.BlobStorage.Contracts; public static class CommonFileNames { public const string ProfilePicture = "profilePicture"; + public const string LogoPicture = "logoPicture"; public const string BannerPicture = "bannerPicture"; } diff --git a/backend/Infrastructure/BlobStorage/Contracts/ContainerNames.cs b/backend/Infrastructure/BlobStorage/Contracts/ContainerNames.cs index ab88a39..96f56ac 100644 --- a/backend/Infrastructure/BlobStorage/Contracts/ContainerNames.cs +++ b/backend/Infrastructure/BlobStorage/Contracts/ContainerNames.cs @@ -1,7 +1,8 @@ -namespace Hutopy.Infrastructure.BlobStorage.Contracts; +namespace Socialize.Infrastructure.BlobStorage.Contracts; internal static class ContainerNames { public const string Users = "users"; + public const string Clients = "clients"; public const string Creators = "creators"; } diff --git a/backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs b/backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs index 1540ccd..569d4c1 100644 --- a/backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs +++ b/backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs @@ -1,6 +1,6 @@ using System.Text; -namespace Hutopy.Infrastructure.BlobStorage.Contracts; +namespace Socialize.Infrastructure.BlobStorage.Contracts; public static class ContentTypes { diff --git a/backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs b/backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs index fd7b19d..ad5fd5c 100644 --- a/backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs +++ b/backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Infrastructure.BlobStorage.Contracts; +namespace Socialize.Infrastructure.BlobStorage.Contracts; public interface IBlobStorage { diff --git a/backend/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs b/backend/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs index cd2fb40..e61f412 100644 --- a/backend/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs +++ b/backend/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Infrastructure.BlobStorage.Contracts; +namespace Socialize.Infrastructure.BlobStorage.Contracts; public static class SubDirectoryNames { diff --git a/backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs b/backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs index 44f5e3e..ad09d41 100644 --- a/backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs +++ b/backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs @@ -1,9 +1,9 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Hutopy.Infrastructure.BlobStorage.Contracts; +using Socialize.Infrastructure.BlobStorage.Contracts; -namespace Hutopy.Infrastructure.BlobStorage.Services; +namespace Socialize.Infrastructure.BlobStorage.Services; public class AzureBlobStorage : IBlobStorage { diff --git a/backend/Infrastructure/Configuration/WebsiteOptions.cs b/backend/Infrastructure/Configuration/WebsiteOptions.cs index 25fe8eb..3e80314 100644 --- a/backend/Infrastructure/Configuration/WebsiteOptions.cs +++ b/backend/Infrastructure/Configuration/WebsiteOptions.cs @@ -1,8 +1,8 @@ -namespace Hutopy.Infrastructure.Configuration; +namespace Socialize.Infrastructure.Configuration; public class WebsiteOptions { public const string SectionName = "Website"; - public string FrontendBaseUrl { get; set; } = "https://localhost:5173"; + public string FrontendBaseUrl { get; set; } = "http://localhost:5173"; } diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index 3fb959b..6b2212e 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -1,15 +1,12 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Infrastructure.BlobStorage.Services; -using Hutopy.Infrastructure.Configuration; -using Hutopy.Infrastructure.Emailer.Configuration; -using Hutopy.Infrastructure.Emailer.Contracts; -using Hutopy.Infrastructure.Emailer.Services; -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Infrastructure.Payments.Stripe.Services; -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Tipping.Contracts; +using Socialize.Infrastructure.BlobStorage.Contracts; +using Socialize.Infrastructure.BlobStorage.Services; +using Socialize.Infrastructure.Configuration; +using Socialize.Infrastructure.Emailer.Configuration; +using Socialize.Infrastructure.Emailer.Contracts; +using Socialize.Infrastructure.Emailer.Services; +using Socialize.Infrastructure.Payments.Stripe.Configuration; -namespace Hutopy.Infrastructure; +namespace Socialize.Infrastructure; public static class DependencyInjection { @@ -20,11 +17,6 @@ public static class DependencyInjection builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName)); builder.Services.AddTransient(); - - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.Configure( builder.Configuration.GetSection(StripeOptions.ConfigurationSection)); diff --git a/backend/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/Infrastructure/Development/DevelopmentSeedExtensions.cs new file mode 100644 index 0000000..9f89d14 --- /dev/null +++ b/backend/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -0,0 +1,633 @@ +using System.Security.Claims; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Contracts; +using Socialize.Modules.Identity.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Socialize.Infrastructure.Development; + +public static class DevelopmentSeedExtensions +{ + private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333"); + private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444"); + private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555"); + private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + private static readonly Guid ScopedApprovalRequestId = Guid.Parse("66666666-6666-6666-6666-666666666666"); + private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777"); + private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888"); + + public static async Task UseDevelopmentSeedAsync( + this IApplicationBuilder app, + CancellationToken cancellationToken = default) + { + IHostEnvironment environment = app.ApplicationServices.GetRequiredService(); + if (!environment.IsDevelopment()) + { + return app; + } + + using IServiceScope scope = app.ApplicationServices.CreateScope(); + IOptions options = scope.ServiceProvider.GetRequiredService>(); + if (!options.Value.Enabled) + { + return app; + } + + UserManager userManager = scope.ServiceProvider.GetRequiredService(); + AppDbContext dbContext = scope.ServiceProvider.GetRequiredService(); + + await RemoveLegacyDevUserAsync(userManager); + + User manager = await EnsureUserAsync( + userManager, + id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + username: "manager", + email: "manager@socialize.local", + password: "manager", + alias: "Northstar Manager", + firstname: "Morgan", + lastname: "Reid", + portraitUrl: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80", + roles: [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember], + claims: + [ + new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()), + ]); + + User clientUser = await EnsureUserAsync( + userManager, + id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + username: "client", + email: "client@socialize.local", + password: "client", + alias: "Sofia Martin", + firstname: "Sofia", + lastname: "Martin", + portraitUrl: "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80", + roles: [KnownRoles.Client, KnownRoles.WorkspaceMember], + claims: + [ + new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()), + new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()), + ]); + + User provider = await EnsureUserAsync( + userManager, + id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), + username: "provider", + email: "provider@socialize.local", + password: "provider", + alias: "Alex Studio", + firstname: "Alex", + lastname: "Studio", + portraitUrl: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80", + roles: [KnownRoles.Provider, KnownRoles.WorkspaceMember], + claims: + [ + new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()), + new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()), + new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()), + ]); + + await EnsureWorkspaceDataAsync( + manager.Id, + clientUser.Id, + provider.Id, + dbContext, + cancellationToken); + + return app; + } + + private static async Task RemoveLegacyDevUserAsync(UserManager userManager) + { + User? legacyUser = await userManager.FindByNameAsync("dev") + ?? await userManager.FindByEmailAsync("dev@socialize.local"); + + if (legacyUser is null) + { + return; + } + + await userManager.DeleteAsync(legacyUser); + } + + private static async Task EnsureUserAsync( + UserManager userManager, + Guid id, + string username, + string email, + string password, + string alias, + string firstname, + string lastname, + string? portraitUrl, + IReadOnlyCollection roles, + IReadOnlyCollection claims) + { + User? user = await userManager.FindByNameAsync(username) + ?? await userManager.FindByEmailAsync(email); + + if (user is null) + { + user = new User + { + Id = id, + UserName = username, + Email = email, + Alias = alias, + Firstname = firstname, + Lastname = lastname, + PortraitUrl = portraitUrl, + EmailConfirmed = true, + }; + + IdentityResult createResult = await userManager.CreateAsync(user, password); + if (!createResult.Succeeded) + { + throw new InvalidOperationException( + $"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}"); + } + } + + user.UserName = username; + user.Email = email; + user.Alias = alias; + user.Firstname = firstname; + user.Lastname = lastname; + user.PortraitUrl = portraitUrl; + user.EmailConfirmed = true; + await userManager.UpdateAsync(user); + + if (!await userManager.CheckPasswordAsync(user, password)) + { + string resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + IdentityResult passwordResetResult = await userManager.ResetPasswordAsync(user, resetToken, password); + if (!passwordResetResult.Succeeded) + { + throw new InvalidOperationException( + $"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}"); + } + } + + IList existingRoles = await userManager.GetRolesAsync(user); + foreach (string role in roles.Except(existingRoles, StringComparer.Ordinal)) + { + await userManager.AddToRoleAsync(user, role); + } + + foreach (string role in existingRoles + .Where(role => role is KnownRoles.Manager or KnownRoles.Client or KnownRoles.Provider or KnownRoles.Administrator or KnownRoles.WorkspaceMember) + .Except(roles, StringComparer.Ordinal)) + { + await userManager.RemoveFromRoleAsync(user, role); + } + + IList existingClaims = await userManager.GetClaimsAsync(user); + List managedClaims = existingClaims + .Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona) + .ToList(); + + foreach (Claim claim in managedClaims) + { + await userManager.RemoveClaimAsync(user, claim); + } + + string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) + ? KnownRoles.Manager + : roles.Contains(KnownRoles.Client, StringComparer.Ordinal) + ? KnownRoles.Client + : roles.Contains(KnownRoles.Provider, StringComparer.Ordinal) + ? KnownRoles.Provider + : KnownRoles.WorkspaceMember; + + foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)])) + { + await userManager.AddClaimAsync(user, claim); + } + + return user; + } + + private static async Task EnsureWorkspaceDataAsync( + Guid managerUserId, + Guid clientUserId, + Guid providerUserId, + AppDbContext dbContext, + CancellationToken cancellationToken) + { + Workspace? workspace = await dbContext.Workspaces + .SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken); + if (workspace is null) + { + workspace = new Workspace + { + Id = WorkspaceId, + Name = string.Empty, + Slug = string.Empty, + TimeZone = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Workspaces.Add(workspace); + } + + workspace.Name = "Northstar Studio"; + workspace.Slug = "northstar-studio"; + workspace.OwnerUserId = managerUserId; + workspace.TimeZone = "America/Montreal"; + await dbContext.SaveChangesAsync(cancellationToken); + + await UpsertClientAsync( + dbContext, + ScopedClientId, + "Luma Coffee", + "Active", + "https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80", + "Sofia Martin", + "client@socialize.local", + WorkspaceId, + cancellationToken); + await UpsertClientAsync( + dbContext, + HiddenClientId, + "Atlas Bakery", + "Active", + "https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80", + "Nina Cole", + "nina@atlasbakery.test", + WorkspaceId, + cancellationToken); + + await UpsertProjectAsync( + dbContext, + ScopedProjectId, + WorkspaceId, + ScopedClientId, + "Spring Launch", + "In progress", + DateTimeOffset.UtcNow.AddDays(1), + DateTimeOffset.UtcNow.AddDays(7), + "Cross-channel launch campaign for the spring offer.", + "Coordinate creative approvals before the final week.", + cancellationToken); + await UpsertProjectAsync( + dbContext, + HiddenProjectId, + WorkspaceId, + HiddenClientId, + "Summer Retention", + "Planned", + DateTimeOffset.UtcNow.AddDays(10), + DateTimeOffset.UtcNow.AddDays(16), + "Retention campaign aimed at existing subscribers.", + "Sequence email and paid social updates together.", + cancellationToken); + + await UpsertContentItemAsync( + dbContext, + ScopedContentItemId, + WorkspaceId, + ScopedClientId, + ScopedProjectId, + "Spring launch hero video", + "Fresh seasonal menu launch across Instagram and TikTok.", + "Instagram Reel, TikTok", + "In client review", + DateTimeOffset.UtcNow.AddDays(3), + "v3", + 3, + cancellationToken); + await UpsertContentItemAsync( + dbContext, + HiddenContentItemId, + WorkspaceId, + HiddenClientId, + HiddenProjectId, + "Bakery loyalty carousel", + "Reward regular customers with a four-card retention carousel.", + "Instagram Carousel", + "Draft", + DateTimeOffset.UtcNow.AddDays(10), + "v1", + 1, + cancellationToken); + + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); + + Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken); + if (asset is null) + { + asset = new Asset + { + Id = ScopedAssetId, + AssetType = string.Empty, + SourceType = string.Empty, + DisplayName = string.Empty, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-4), + }; + dbContext.Assets.Add(asset); + } + asset.WorkspaceId = WorkspaceId; + asset.ContentItemId = ScopedContentItemId; + asset.AssetType = "Video"; + asset.SourceType = "GoogleDrive"; + asset.DisplayName = "Spring launch cut"; + asset.GoogleDriveFileId = "dev-socialize-demo"; + asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view"; + asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo"; + asset.CurrentRevisionNumber = 2; + await dbContext.SaveChangesAsync(cancellationToken); + + await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000001"), ScopedAssetId, 1, "https://drive.google.com/file/d/dev-socialize-demo-v1/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v1", "First uploaded cut from the editor.", providerUserId, DateTimeOffset.UtcNow.AddDays(-4), cancellationToken); + await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000002"), ScopedAssetId, 2, "https://drive.google.com/file/d/dev-socialize-demo-v2/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v2", "Re-export with pacing changes and updated title card.", providerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); + + Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == ClientCommentId, cancellationToken); + if (comment is null) + { + comment = new Comment + { + Id = ClientCommentId, + AuthorDisplayName = string.Empty, + AuthorEmail = string.Empty, + Body = string.Empty, + CreatedAt = DateTimeOffset.UtcNow.AddHours(-20), + }; + dbContext.Comments.Add(comment); + } + comment.WorkspaceId = WorkspaceId; + comment.ContentItemId = ScopedContentItemId; + comment.AuthorUserId = clientUserId; + comment.AuthorDisplayName = "Sofia Martin"; + comment.AuthorEmail = "client@socialize.local"; + comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit."; + comment.IsResolved = false; + comment.ResolvedAt = null; + await dbContext.SaveChangesAsync(cancellationToken); + + ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken); + if (approvalRequest is null) + { + approvalRequest = new ApprovalRequest + { + Id = ScopedApprovalRequestId, + Stage = string.Empty, + ReviewerName = string.Empty, + ReviewerEmail = string.Empty, + State = string.Empty, + AccessToken = string.Empty, + SentAt = DateTimeOffset.UtcNow.AddHours(-12), + }; + dbContext.ApprovalRequests.Add(approvalRequest); + } + approvalRequest.WorkspaceId = WorkspaceId; + approvalRequest.ContentItemId = ScopedContentItemId; + approvalRequest.Stage = "Client"; + approvalRequest.ReviewerName = "Sofia Martin"; + approvalRequest.ReviewerEmail = "client@socialize.local"; + approvalRequest.RequestedByUserId = managerUserId; + approvalRequest.DueAt = DateTimeOffset.UtcNow.AddDays(1); + approvalRequest.State = "Pending"; + approvalRequest.AccessToken = "seed-client-review-token"; + approvalRequest.CompletedAt = null; + await dbContext.SaveChangesAsync(cancellationToken); + + NotificationEvent? approvalNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == NotificationId, cancellationToken); + if (approvalNotification is null) + { + approvalNotification = new NotificationEvent + { + Id = NotificationId, + EventType = string.Empty, + EntityType = string.Empty, + Message = string.Empty, + CreatedAt = DateTimeOffset.UtcNow.AddHours(-12), + }; + dbContext.NotificationEvents.Add(approvalNotification); + } + approvalNotification.WorkspaceId = WorkspaceId; + approvalNotification.ContentItemId = ScopedContentItemId; + approvalNotification.EventType = "approval.requested"; + approvalNotification.EntityType = "ApprovalRequest"; + approvalNotification.EntityId = ScopedApprovalRequestId; + approvalNotification.Message = "Approval requested from Sofia Martin for Spring launch hero video."; + approvalNotification.RecipientEmail = "client@socialize.local"; + approvalNotification.MetadataJson = """{"stage":"Client"}"""; + approvalNotification.ReadAt = null; + Guid commentNotificationId = Guid.Parse("88888888-8888-8888-8888-000000000002"); + NotificationEvent? commentNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == commentNotificationId, cancellationToken); + if (commentNotification is null) + { + commentNotification = new NotificationEvent + { + Id = commentNotificationId, + EventType = string.Empty, + EntityType = string.Empty, + Message = string.Empty, + CreatedAt = DateTimeOffset.UtcNow.AddHours(-20), + }; + dbContext.NotificationEvents.Add(commentNotification); + } + commentNotification.WorkspaceId = WorkspaceId; + commentNotification.ContentItemId = ScopedContentItemId; + commentNotification.EventType = "comment.created"; + commentNotification.EntityType = "Comment"; + commentNotification.EntityId = ClientCommentId; + commentNotification.Message = "Sofia Martin commented on Spring launch hero video."; + commentNotification.RecipientUserId = managerUserId; + commentNotification.RecipientEmail = "manager@socialize.local"; + commentNotification.MetadataJson = null; + commentNotification.ReadAt = null; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task UpsertClientAsync( + AppDbContext dbContext, + Guid id, + string name, + string status, + string portraitUrl, + string primaryContactName, + string primaryContactEmail, + Guid workspaceId, + CancellationToken cancellationToken) + { + Client? client = await dbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (client is null) + { + client = new Client + { + Id = id, + Name = string.Empty, + Status = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Clients.Add(client); + } + client.WorkspaceId = workspaceId; + client.Name = name; + client.Status = status; + client.PortraitUrl = portraitUrl; + client.PrimaryContactName = primaryContactName; + client.PrimaryContactEmail = primaryContactEmail; + client.PrimaryContactPortraitUrl = null; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task UpsertProjectAsync( + AppDbContext dbContext, + Guid id, + Guid workspaceId, + Guid clientId, + string name, + string status, + DateTimeOffset startDate, + DateTimeOffset endDate, + string? description, + string? notes, + CancellationToken cancellationToken) + { + Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (project is null) + { + project = new Project + { + Id = id, + Name = string.Empty, + Status = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Projects.Add(project); + } + project.WorkspaceId = workspaceId; + project.ClientId = clientId; + project.Name = name; + project.Description = description; + project.Notes = notes; + project.Status = status; + project.StartDate = startDate; + project.EndDate = endDate; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task UpsertContentItemAsync( + AppDbContext dbContext, + Guid id, + Guid workspaceId, + Guid clientId, + Guid projectId, + string title, + string publicationMessage, + string publicationTargets, + string status, + DateTimeOffset? dueDate, + string currentRevisionLabel, + int currentRevisionNumber, + CancellationToken cancellationToken) + { + ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (item is null) + { + item = new ContentItem + { + Id = id, + Title = string.Empty, + PublicationMessage = string.Empty, + PublicationTargets = string.Empty, + Status = string.Empty, + CurrentRevisionLabel = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.ContentItems.Add(item); + } + item.WorkspaceId = workspaceId; + item.ClientId = clientId; + item.ProjectId = projectId; + item.Title = title; + item.PublicationMessage = publicationMessage; + item.PublicationTargets = publicationTargets; + item.Status = status; + item.DueDate = dueDate; + item.CurrentRevisionLabel = currentRevisionLabel; + item.CurrentRevisionNumber = currentRevisionNumber; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task EnsureRevisionAsync( + AppDbContext dbContext, + Guid id, + Guid contentItemId, + int revisionNumber, + string revisionLabel, + string title, + string publicationMessage, + string publicationTargets, + string changeSummary, + Guid createdByUserId, + DateTimeOffset createdAt, + CancellationToken cancellationToken) + { + ContentItemRevision? revision = await dbContext.ContentItemRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (revision is null) + { + revision = new ContentItemRevision + { + Id = id, + RevisionLabel = string.Empty, + Title = string.Empty, + PublicationMessage = string.Empty, + PublicationTargets = string.Empty, + CreatedAt = createdAt, + }; + dbContext.ContentItemRevisions.Add(revision); + } + revision.ContentItemId = contentItemId; + revision.RevisionNumber = revisionNumber; + revision.RevisionLabel = revisionLabel; + revision.Title = title; + revision.PublicationMessage = publicationMessage; + revision.PublicationTargets = publicationTargets; + revision.ChangeSummary = changeSummary; + revision.CreatedByUserId = createdByUserId; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task EnsureAssetRevisionAsync( + AppDbContext dbContext, + Guid id, + Guid assetId, + int revisionNumber, + string sourceReference, + string? previewUrl, + string? notes, + Guid createdByUserId, + DateTimeOffset createdAt, + CancellationToken cancellationToken) + { + AssetRevision? revision = await dbContext.AssetRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (revision is null) + { + revision = new AssetRevision + { + Id = id, + SourceReference = string.Empty, + CreatedAt = createdAt, + }; + dbContext.AssetRevisions.Add(revision); + } + revision.AssetId = assetId; + revision.RevisionNumber = revisionNumber; + revision.SourceReference = sourceReference; + revision.PreviewUrl = previewUrl; + revision.Notes = notes; + revision.CreatedByUserId = createdByUserId; + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/Infrastructure/Development/DevelopmentSeedOptions.cs b/backend/Infrastructure/Development/DevelopmentSeedOptions.cs new file mode 100644 index 0000000..5a0b8a7 --- /dev/null +++ b/backend/Infrastructure/Development/DevelopmentSeedOptions.cs @@ -0,0 +1,8 @@ +namespace Socialize.Infrastructure.Development; + +public record DevelopmentSeedOptions +{ + public const string SectionName = "DevelopmentSeed"; + + public bool Enabled { get; init; } = true; +} diff --git a/backend/Infrastructure/Emailer/Configuration/EmailerOptions.cs b/backend/Infrastructure/Emailer/Configuration/EmailerOptions.cs index fc82315..2cef6b7 100644 --- a/backend/Infrastructure/Emailer/Configuration/EmailerOptions.cs +++ b/backend/Infrastructure/Emailer/Configuration/EmailerOptions.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Infrastructure.Emailer.Configuration; +namespace Socialize.Infrastructure.Emailer.Configuration; public class EmailerOptions { diff --git a/backend/Infrastructure/Emailer/Contracts/IEmailSender.cs b/backend/Infrastructure/Emailer/Contracts/IEmailSender.cs index f9107ac..ee1a065 100644 --- a/backend/Infrastructure/Emailer/Contracts/IEmailSender.cs +++ b/backend/Infrastructure/Emailer/Contracts/IEmailSender.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Infrastructure.Emailer.Contracts; +namespace Socialize.Infrastructure.Emailer.Contracts; public interface IEmailSender { diff --git a/backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs b/backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs index 7c8c46f..c99138f 100644 --- a/backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs +++ b/backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs @@ -1,6 +1,6 @@ -using Hutopy.Infrastructure.Emailer.Contracts; +using Socialize.Infrastructure.Emailer.Contracts; -namespace Hutopy.Infrastructure.Emailer.Services; +namespace Socialize.Infrastructure.Emailer.Services; public class LoggerEmailSender(ILogger logger) : IEmailSender diff --git a/backend/Infrastructure/Emailer/Services/PostmarkEmailSender.cs b/backend/Infrastructure/Emailer/Services/PostmarkEmailSender.cs index 4a14252..0c03a8d 100644 --- a/backend/Infrastructure/Emailer/Services/PostmarkEmailSender.cs +++ b/backend/Infrastructure/Emailer/Services/PostmarkEmailSender.cs @@ -1,9 +1,9 @@ -using Hutopy.Infrastructure.Emailer.Configuration; -using Hutopy.Infrastructure.Emailer.Contracts; +using Socialize.Infrastructure.Emailer.Configuration; +using Socialize.Infrastructure.Emailer.Contracts; using Microsoft.Extensions.Options; using PostmarkDotNet; -namespace Hutopy.Infrastructure.Emailer.Services; +namespace Socialize.Infrastructure.Emailer.Services; public class PostmarkEmailSender : IEmailSender { diff --git a/backend/Infrastructure/Emailer/Services/ResendEmailSender.cs b/backend/Infrastructure/Emailer/Services/ResendEmailSender.cs index b400dd4..7fa7360 100644 --- a/backend/Infrastructure/Emailer/Services/ResendEmailSender.cs +++ b/backend/Infrastructure/Emailer/Services/ResendEmailSender.cs @@ -1,11 +1,11 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using Hutopy.Infrastructure.Emailer.Configuration; -using Hutopy.Infrastructure.Emailer.Contracts; +using Socialize.Infrastructure.Emailer.Configuration; +using Socialize.Infrastructure.Emailer.Contracts; using Microsoft.Extensions.Options; -namespace Hutopy.Infrastructure.Emailer.Services; +namespace Socialize.Infrastructure.Emailer.Services; public class ResendEmailSender : IEmailSender { diff --git a/backend/Infrastructure/Payments/Stripe/Configuration/StripeOptions.cs b/backend/Infrastructure/Payments/Stripe/Configuration/StripeOptions.cs index 594aca4..be35dd6 100644 --- a/backend/Infrastructure/Payments/Stripe/Configuration/StripeOptions.cs +++ b/backend/Infrastructure/Payments/Stripe/Configuration/StripeOptions.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Hutopy.Infrastructure.Payments.Stripe.Configuration; +namespace Socialize.Infrastructure.Payments.Stripe.Configuration; public class StripeOptions { @@ -10,5 +10,5 @@ public class StripeOptions [Required] public required string WebhookSecret { get; init; } - [Required] [Range(0, 1)] public required decimal HutopyRate { get; init; } + [Required] [Range(0, 1)] public required decimal SocializeRate { get; init; } } diff --git a/backend/Infrastructure/Payments/Stripe/Services/MembershipCancellationProcessor.cs b/backend/Infrastructure/Payments/Stripe/Services/MembershipCancellationProcessor.cs deleted file mode 100644 index 4d8725d..0000000 --- a/backend/Infrastructure/Payments/Stripe/Services/MembershipCancellationProcessor.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Hutopy.Modules.Memberships.Contracts; -using Stripe; - -namespace Hutopy.Infrastructure.Payments.Stripe.Services; - -public sealed class MembershipCancellationProcessor - : IMembershipCancellationProcessor -{ - public async Task CancelAsync( - string subscriptionId, - CancellationToken ct = default) - { - SubscriptionService subscriptionService = new(); - - // Stripe - Cancel Subscription immediately - // var subscription = await subscriptionService.CancelAsync( - // subscriptionId, - // cancellationToken: ct); - - // Stripe - Cancel Subscription AtPeriodEnd - Subscription? subscription = await subscriptionService.UpdateAsync( - subscriptionId, - new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }, - cancellationToken: ct); - - return subscription.CancelAt ?? subscription.CanceledAt; - } -} diff --git a/backend/Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs b/backend/Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs deleted file mode 100644 index 84a2b71..0000000 --- a/backend/Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Memberships.Contracts; -using Microsoft.Extensions.Options; -using Stripe; -using Stripe.Checkout; - -namespace Hutopy.Infrastructure.Payments.Stripe.Services; - -public class MembershipPaymentProcessor( - IOptions stripeOptions) - : IMembershipPaymentProcessor -{ - public async Task CreateCheckoutSessionAsync( - Guid userId, - CreatorReference creatorReference, - Guid tierId, - string priceId, - string successUrl, - string cancelUrl) - { - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - - // Create Stripe customer for the user if not already created - CustomerService customerService = new(); - Customer? customer = await customerService.CreateAsync( - new CustomerCreateOptions - { - Metadata = new Dictionary { { "userId", userId.ToString() } } - }); - - // Create Checkout Session for the subscription - SessionService sessionService = new(); - Session? session = await sessionService.CreateAsync( - new SessionCreateOptions - { - Customer = customer.Id, - PaymentMethodTypes = ["card"], - LineItems = - [ - new SessionLineItemOptions { Price = priceId, Quantity = 1 } - ], - Mode = "subscription", - SubscriptionData = new SessionSubscriptionDataOptions - { - ApplicationFeePercent = stripeOptions.Value.HutopyRate, - TransferData = - new SessionSubscriptionDataTransferDataOptions - { - Destination = creatorReference.StripeAccountId - } - }, - SuccessUrl = successUrl, // Redirect after successful payment - CancelUrl = cancelUrl, // Redirect after canceled payment - Metadata = new Dictionary - { - { "userId", userId.ToString() }, - { "creatorId", creatorReference.Id.ToString() }, - { "creatorName", creatorReference.Name }, - { "tierId", tierId.ToString() } - } - }); - - return new MembershipCheckoutSession( - session.Id, - session.Url); - } -} diff --git a/backend/Infrastructure/Payments/Stripe/Services/MembershipTierProcessor.cs b/backend/Infrastructure/Payments/Stripe/Services/MembershipTierProcessor.cs deleted file mode 100644 index d12864f..0000000 --- a/backend/Infrastructure/Payments/Stripe/Services/MembershipTierProcessor.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Modules.Memberships.Contracts; -using Microsoft.Extensions.Options; -using Stripe; - -namespace Hutopy.Infrastructure.Payments.Stripe.Services; - -public sealed class MembershipTierProcessor( - IOptions stripeOptions) - : IMembershipTierProcessor -{ - public async Task CreateAsync( - Guid creatorId, - Guid tierId, - string productName, - string currencyCode, - decimal amount) - { - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - - // Create the product - ProductService productService = new(); - Product? product = await productService.CreateAsync( - new ProductCreateOptions - { - Name = productName, - Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } } - }); - - // Create the price for the product - PriceService priceService = new(); - await priceService.CreateAsync( - new PriceCreateOptions - { - Product = product.Id, - UnitAmountDecimal = amount * 100, // Convert amount to cents - Currency = currencyCode, - Recurring = new PriceRecurringOptions { Interval = "month" } - }); - - return product.Id; - } -} diff --git a/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs b/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs deleted file mode 100644 index 71b0f4a..0000000 --- a/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Tipping.Contracts; -using Microsoft.Extensions.Options; -using Stripe; -using Stripe.Checkout; - -namespace Hutopy.Infrastructure.Payments.Stripe.Services; - -internal class StripeTipProcessor( - IOptions stripeOptions) - : ITipProcessor -{ - public async Task CreateCheckoutSessionAsync( - Guid tipId, - CreatorReference creator, - decimal amount, - string currency, - string message, - Uri successUrl, - Uri cancelUrl, - CancellationToken ct = default) - { - var applicationFeeAmount = Convert.ToInt64(amount * stripeOptions.Value.HutopyRate); - - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - - var sessionService = new SessionService(); - - var options = new SessionCreateOptions - { - ClientReferenceId = tipId.ToString(), - Mode = "payment", - LineItems = - [ - new SessionLineItemOptions - { - PriceData = new SessionLineItemPriceDataOptions - { - Currency = currency, - UnitAmountDecimal = amount, // Amount in cents - ProductData = new SessionLineItemPriceDataProductDataOptions - { - Name = $"Tip for {creator.Name}", - Metadata = new Dictionary { { "creatorId", creator.Id.ToString() } } - } - }, - Quantity = 1 - } - ], - PaymentIntentData = new SessionPaymentIntentDataOptions { ApplicationFeeAmount = applicationFeeAmount }, - Metadata = new Dictionary - { - { "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message } - }, - SuccessUrl = successUrl.ToString(), // Redirect after successful payment - CancelUrl = cancelUrl.ToString(), // Redirect after canceled payment - }; - - var requestOptions = new RequestOptions { StripeAccount = creator.StripeAccountId }; - - var session = await sessionService.CreateAsync( - options, - requestOptions, - cancellationToken: ct) - .ConfigureAwait(false); - - return new TipCheckoutSession(session.Id, session.Url); - } -} diff --git a/backend/Infrastructure/Security/AccessScopeService.cs b/backend/Infrastructure/Security/AccessScopeService.cs new file mode 100644 index 0000000..11771a4 --- /dev/null +++ b/backend/Infrastructure/Security/AccessScopeService.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using Socialize.Modules.Identity.Contracts; + +namespace Socialize.Infrastructure.Security; + +public sealed class AccessScopeService +{ + public bool IsManager(ClaimsPrincipal user) + { + return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); + } + + public bool IsProvider(ClaimsPrincipal user) + { + return user.IsInRole(KnownRoles.Provider); + } + + public bool IsClient(ClaimsPrincipal user) + { + return user.IsInRole(KnownRoles.Client); + } + + public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId) + { + return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId); + } + + public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) + { + return IsManager(user) && CanAccessWorkspace(user, workspaceId); + } + + public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId) + { + return IsManager(user) + || (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); + } + + public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + { + return IsManager(user) + || (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId)); + } + + public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + { + return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)); + } + + public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + { + return IsManager(user) + || IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId) + || IsClient(user) && CanAccessClient(user, workspaceId, clientId); + } +} diff --git a/backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs b/backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs index dd8a78e..b47a62e 100644 --- a/backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs +++ b/backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs @@ -1,9 +1,38 @@ using System.Security.Claims; -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; public static class ClaimsPrincipalExtensions { + public static IReadOnlyCollection GetScopeIds(this ClaimsPrincipal claims, string key) + { + return claims.FindAll(key) + .Select(claim => Guid.TryParse(claim.Value, out Guid value) ? value : Guid.Empty) + .Where(value => value != Guid.Empty) + .Distinct() + .ToArray(); + } + + public static IReadOnlyCollection GetWorkspaceScopeIds(this ClaimsPrincipal claims) + { + return claims.GetScopeIds(KnownClaims.WorkspaceScope); + } + + public static IReadOnlyCollection GetClientScopeIds(this ClaimsPrincipal claims) + { + return claims.GetScopeIds(KnownClaims.ClientScope); + } + + public static IReadOnlyCollection GetProjectScopeIds(this ClaimsPrincipal claims) + { + return claims.GetScopeIds(KnownClaims.ProjectScope); + } + + public static string? GetPersona(this ClaimsPrincipal claims) + { + return (string?)claims.GetClaim(KnownClaims.Persona); + } + public static Guid GetUserId(this ClaimsPrincipal claims) { return (Guid)claims.GetRequiredClaim(ClaimTypes.NameIdentifier); diff --git a/backend/Infrastructure/Security/GenerateJwtToken.cs b/backend/Infrastructure/Security/GenerateJwtToken.cs index 3e88aff..09fcc3a 100644 --- a/backend/Infrastructure/Security/GenerateJwtToken.cs +++ b/backend/Infrastructure/Security/GenerateJwtToken.cs @@ -3,7 +3,7 @@ using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; public static class JwtTokenHelper { @@ -17,7 +17,9 @@ public static class JwtTokenHelper string? alias, string firstname, string lastname, - string? portraitUrl) + string? portraitUrl, + IEnumerable roles, + IEnumerable additionalClaims) { SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(key)); SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256); @@ -40,6 +42,18 @@ public static class JwtTokenHelper claims.Add(new Claim(KnownClaims.PortraitUrl, portraitUrl)); } + foreach (string role in roles.Distinct(StringComparer.Ordinal)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + foreach (Claim claim in additionalClaims + .Where(claim => !string.IsNullOrWhiteSpace(claim.Type) && !string.IsNullOrWhiteSpace(claim.Value)) + .DistinctBy(claim => $"{claim.Type}:{claim.Value}")) + { + claims.Add(claim); + } + JwtSecurityToken token = new( issuer, audience, diff --git a/backend/Infrastructure/Security/KnownClaims.cs b/backend/Infrastructure/Security/KnownClaims.cs index 9bcb515..18c91f7 100644 --- a/backend/Infrastructure/Security/KnownClaims.cs +++ b/backend/Infrastructure/Security/KnownClaims.cs @@ -1,7 +1,11 @@ -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; public static class KnownClaims { public const string Alias = "alias"; public const string PortraitUrl = "portraitUrl"; + public const string WorkspaceScope = "workspace"; + public const string ClientScope = "client"; + public const string ProjectScope = "project"; + public const string Persona = "persona"; } diff --git a/backend/Infrastructure/Security/MissingClaimException.cs b/backend/Infrastructure/Security/MissingClaimException.cs index f7b49ce..ed4f285 100644 --- a/backend/Infrastructure/Security/MissingClaimException.cs +++ b/backend/Infrastructure/Security/MissingClaimException.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; public class MissingClaimException( string claimName) diff --git a/backend/Infrastructure/Security/PasswordGenerator.cs b/backend/Infrastructure/Security/PasswordGenerator.cs index d1ae540..b6ffe09 100644 --- a/backend/Infrastructure/Security/PasswordGenerator.cs +++ b/backend/Infrastructure/Security/PasswordGenerator.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; // If we need to add special characters we can alternate between 2 pools. public static class PasswordGenerator diff --git a/backend/Infrastructure/Security/RefreshTokenGenerator.cs b/backend/Infrastructure/Security/RefreshTokenGenerator.cs index 3a786e4..15911d1 100644 --- a/backend/Infrastructure/Security/RefreshTokenGenerator.cs +++ b/backend/Infrastructure/Security/RefreshTokenGenerator.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace Hutopy.Infrastructure.Security; +namespace Socialize.Infrastructure.Security; public static class RefreshTokenGenerator { diff --git a/backend/Infrastructure/YouTube/YouTubeUrlHelper.cs b/backend/Infrastructure/YouTube/YouTubeUrlHelper.cs index 7a07527..e25e162 100644 --- a/backend/Infrastructure/YouTube/YouTubeUrlHelper.cs +++ b/backend/Infrastructure/YouTube/YouTubeUrlHelper.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Hutopy.Infrastructure.YouTube; +namespace Socialize.Infrastructure.YouTube; public static class YouTubeUrlHelper { diff --git a/backend/Migrations/20260423061407_Initial.Designer.cs b/backend/Migrations/20260423061407_Initial.Designer.cs new file mode 100644 index 0000000..85478fa --- /dev/null +++ b/backend/Migrations/20260423061407_Initial.Designer.cs @@ -0,0 +1,942 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260423061407_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Migrations/20260423061407_Initial.cs b/backend/Migrations/20260423061407_Initial.cs new file mode 100644 index 0000000..9d522f9 --- /dev/null +++ b/backend/Migrations/20260423061407_Initial.cs @@ -0,0 +1,657 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApprovalDecisions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ApprovalRequestId = table.Column(type: "uuid", nullable: false), + Decision = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Comment = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + DecidedByUserId = table.Column(type: "uuid", nullable: true), + DecidedByName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DecidedByEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_ApprovalDecisions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ApprovalRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + Stage = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ReviewerName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ReviewerEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + RequestedByUserId = table.Column(type: "uuid", nullable: false), + DueAt = table.Column(type: "timestamp with time zone", nullable: true), + State = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AccessToken = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SentAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApprovalRequests", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Alias = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Firstname = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Lastname = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + BirthDate = table.Column(type: "timestamp with time zone", nullable: true), + Address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + PortraitUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + GoogleId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FacebookId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + RefreshToken = table.Column(type: "character varying(44)", maxLength: 44, nullable: true), + RefreshTokenExpiryTime = table.Column(type: "timestamp with time zone", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AssetRevisions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AssetId = table.Column(type: "uuid", nullable: false), + RevisionNumber = table.Column(type: "integer", nullable: false), + SourceReference = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + PreviewUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + Notes = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + CreatedByUserId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_AssetRevisions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Assets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + AssetType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SourceType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + DisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + GoogleDriveFileId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + GoogleDriveLink = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + PreviewUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CurrentRevisionNumber = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Assets", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Clients", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Status = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PortraitUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + PrimaryContactName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + PrimaryContactEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + PrimaryContactPortraitUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Clients", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Comments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + ParentCommentId = table.Column(type: "uuid", nullable: true), + AuthorUserId = table.Column(type: "uuid", nullable: false), + AuthorDisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + AuthorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Body = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + IsResolved = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + ResolvedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Comments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ContentItemRevisions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + RevisionNumber = table.Column(type: "integer", nullable: false), + RevisionLabel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + PublicationMessage = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + PublicationTargets = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Hashtags = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + ChangeSummary = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + CreatedByUserId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_ContentItemRevisions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ContentItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ClientId = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + PublicationMessage = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + PublicationTargets = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Hashtags = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + Status = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + DueDate = table.Column(type: "timestamp with time zone", nullable: true), + CurrentRevisionLabel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CurrentRevisionNumber = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_ContentItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NotificationEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: true), + EventType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + EntityType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + EntityId = table.Column(type: "uuid", nullable: false), + Message = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + RecipientUserId = table.Column(type: "uuid", nullable: true), + RecipientEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + MetadataJson = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationEvents", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ClientId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + Notes = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + Status = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + StartDate = table.Column(type: "timestamp with time zone", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WorkspaceInvites", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Role = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + InvitedByUserId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_WorkspaceInvites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Workspaces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + OwnerUserId = table.Column(type: "uuid", nullable: false), + TimeZone = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Workspaces", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalDecisions_ApprovalRequestId", + table: "ApprovalDecisions", + column: "ApprovalRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalRequests_ContentItemId", + table: "ApprovalRequests", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalRequests_ReviewerEmail", + table: "ApprovalRequests", + column: "ReviewerEmail"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalRequests_WorkspaceId", + table: "ApprovalRequests", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AssetRevisions_AssetId", + table: "AssetRevisions", + column: "AssetId"); + + migrationBuilder.CreateIndex( + name: "IX_AssetRevisions_AssetId_RevisionNumber", + table: "AssetRevisions", + columns: new[] { "AssetId", "RevisionNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Assets_ContentItemId", + table: "Assets", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_Assets_WorkspaceId", + table: "Assets", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_Clients_WorkspaceId", + table: "Clients", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_Clients_WorkspaceId_Name", + table: "Clients", + columns: new[] { "WorkspaceId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Comments_ContentItemId", + table: "Comments", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_Comments_ParentCommentId", + table: "Comments", + column: "ParentCommentId"); + + migrationBuilder.CreateIndex( + name: "IX_Comments_WorkspaceId", + table: "Comments", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_ContentItemRevisions_ContentItemId", + table: "ContentItemRevisions", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_ContentItemRevisions_ContentItemId_RevisionNumber", + table: "ContentItemRevisions", + columns: new[] { "ContentItemId", "RevisionNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_ClientId", + table: "ContentItems", + column: "ClientId"); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_ProjectId", + table: "ContentItems", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_ContentItems_WorkspaceId", + table: "ContentItems", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationEvents_ContentItemId", + table: "NotificationEvents", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationEvents_CreatedAt", + table: "NotificationEvents", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationEvents_RecipientUserId", + table: "NotificationEvents", + column: "RecipientUserId"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationEvents_WorkspaceId", + table: "NotificationEvents", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_ClientId", + table: "Projects", + column: "ClientId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_ClientId_Name", + table: "Projects", + columns: new[] { "ClientId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Projects_WorkspaceId", + table: "Projects", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvites_WorkspaceId", + table: "WorkspaceInvites", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvites_WorkspaceId_Email_Status", + table: "WorkspaceInvites", + columns: new[] { "WorkspaceId", "Email", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Workspaces_OwnerUserId", + table: "Workspaces", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Workspaces_Slug", + table: "Workspaces", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApprovalDecisions"); + + migrationBuilder.DropTable( + name: "ApprovalRequests"); + + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AssetRevisions"); + + migrationBuilder.DropTable( + name: "Assets"); + + migrationBuilder.DropTable( + name: "Clients"); + + migrationBuilder.DropTable( + name: "Comments"); + + migrationBuilder.DropTable( + name: "ContentItemRevisions"); + + migrationBuilder.DropTable( + name: "ContentItems"); + + migrationBuilder.DropTable( + name: "NotificationEvents"); + + migrationBuilder.DropTable( + name: "Projects"); + + migrationBuilder.DropTable( + name: "WorkspaceInvites"); + + migrationBuilder.DropTable( + name: "Workspaces"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/backend/Migrations/AppDbContextModelSnapshot.cs b/backend/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..9686d9b --- /dev/null +++ b/backend/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,939 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Modules/Approvals/Data/ApprovalDecision.cs b/backend/Modules/Approvals/Data/ApprovalDecision.cs new file mode 100644 index 0000000..51fa8ff --- /dev/null +++ b/backend/Modules/Approvals/Data/ApprovalDecision.cs @@ -0,0 +1,13 @@ +namespace Socialize.Modules.Approvals.Data; + +public class ApprovalDecision +{ + public Guid Id { get; init; } + public Guid ApprovalRequestId { get; set; } + public required string Decision { get; set; } + public string? Comment { get; set; } + public Guid? DecidedByUserId { get; set; } + public required string DecidedByName { get; set; } + public required string DecidedByEmail { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Approvals/Data/ApprovalRequest.cs b/backend/Modules/Approvals/Data/ApprovalRequest.cs new file mode 100644 index 0000000..ffee615 --- /dev/null +++ b/backend/Modules/Approvals/Data/ApprovalRequest.cs @@ -0,0 +1,17 @@ +namespace Socialize.Modules.Approvals.Data; + +public class ApprovalRequest +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ContentItemId { get; set; } + public required string Stage { get; set; } + public required string ReviewerName { get; set; } + public required string ReviewerEmail { get; set; } + public Guid RequestedByUserId { get; set; } + public DateTimeOffset? DueAt { get; set; } + public required string State { get; set; } + public required string AccessToken { get; set; } + public DateTimeOffset SentAt { get; init; } + public DateTimeOffset? CompletedAt { get; set; } +} diff --git a/backend/Modules/Approvals/DependencyInjection.cs b/backend/Modules/Approvals/DependencyInjection.cs new file mode 100644 index 0000000..8639729 --- /dev/null +++ b/backend/Modules/Approvals/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.Approvals.Data; + +namespace Socialize.Modules.Approvals; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddApprovalsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs b/backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs new file mode 100644 index 0000000..ee27f83 --- /dev/null +++ b/backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Approvals.Handlers; + +public record CreateApprovalRequestRequest( + Guid WorkspaceId, + Guid ContentItemId, + string Stage, + string ReviewerName, + string ReviewerEmail, + DateTimeOffset? DueAt); + +public class CreateApprovalRequestRequestValidator + : Validator +{ + public CreateApprovalRequestRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.ContentItemId).NotEmpty(); + RuleFor(x => x.Stage).NotEmpty().MaximumLength(64); + RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256); + RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress(); + } +} + +public class CreateApprovalRequestHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/approvals"); + Options(o => o.WithTags("Approvals")); + } + + public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct) + { + ContentItem? contentItem = await dbContext.ContentItems + .SingleOrDefaultAsync( + candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, + ct); + + if (contentItem is null) + { + AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + ApprovalRequest approval = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + ContentItemId = request.ContentItemId, + Stage = request.Stage.Trim(), + ReviewerName = request.ReviewerName.Trim(), + ReviewerEmail = request.ReviewerEmail.Trim(), + RequestedByUserId = User.GetUserId(), + DueAt = request.DueAt, + State = "Pending", + AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(), + SentAt = DateTimeOffset.UtcNow, + }; + + dbContext.ApprovalRequests.Add(approval); + + if (approval.Stage == "Internal") + { + contentItem.Status = "In internal review"; + } + else if (approval.Stage == "Client") + { + contentItem.Status = "In client review"; + } + + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.requested", + "ApprovalRequest", + approval.Id, + $"Approval requested from {approval.ReviewerName} for {contentItem.Title}.", + null, + approval.ReviewerEmail, + $$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""), + ct); + + ApprovalRequestDto dto = new( + approval.Id, + approval.WorkspaceId, + approval.ContentItemId, + approval.Stage, + approval.ReviewerName, + approval.ReviewerEmail, + approval.RequestedByUserId, + approval.DueAt, + approval.State, + approval.AccessToken, + approval.SentAt, + approval.CompletedAt, + []); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Approvals/Handlers/GetApprovals.cs b/backend/Modules/Approvals/Handlers/GetApprovals.cs new file mode 100644 index 0000000..62361a8 --- /dev/null +++ b/backend/Modules/Approvals/Handlers/GetApprovals.cs @@ -0,0 +1,117 @@ +using Socialize.Infrastructure.Security; + +namespace Socialize.Modules.Approvals.Handlers; + +public record GetApprovalsRequest(Guid ContentItemId); + +public record ApprovalDecisionDto( + Guid Id, + Guid ApprovalRequestId, + string Decision, + string? Comment, + Guid? DecidedByUserId, + string DecidedByName, + string DecidedByEmail, + string? DecidedByPortraitUrl, + DateTimeOffset CreatedAt); + +public record ApprovalRequestDto( + Guid Id, + Guid WorkspaceId, + Guid ContentItemId, + string Stage, + string ReviewerName, + string ReviewerEmail, + Guid RequestedByUserId, + DateTimeOffset? DueAt, + string State, + string AccessToken, + DateTimeOffset SentAt, + DateTimeOffset? CompletedAt, + IReadOnlyCollection Decisions); + +public class GetApprovalsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/approvals"); + Options(o => o.WithTags("Approvals")); + } + + public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct) + { + ContentItem? item = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + List approvals = await dbContext.ApprovalRequests + .Where(approval => approval.ContentItemId == request.ContentItemId) + .OrderByDescending(approval => approval.SentAt) + .ToListAsync(ct); + + List approvalIds = approvals + .Select(approval => approval.Id) + .ToList(); + + List decisions = await dbContext.ApprovalDecisions + .Where(decision => approvalIds.Contains(decision.ApprovalRequestId)) + .OrderByDescending(decision => decision.CreatedAt) + .ToListAsync(ct); + + List decidedByUserIds = decisions + .Where(decision => decision.DecidedByUserId.HasValue) + .Select(decision => decision.DecidedByUserId!.Value) + .Distinct() + .ToList(); + + Dictionary decisionPortraits = await dbContext.Users + .Where(user => decidedByUserIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct); + + List dtos = approvals + .Select(approval => new ApprovalRequestDto( + approval.Id, + approval.WorkspaceId, + approval.ContentItemId, + approval.Stage, + approval.ReviewerName, + approval.ReviewerEmail, + approval.RequestedByUserId, + approval.DueAt, + approval.State, + approval.AccessToken, + approval.SentAt, + approval.CompletedAt, + decisions + .Where(decision => decision.ApprovalRequestId == approval.Id) + .Select(decision => new ApprovalDecisionDto( + decision.Id, + decision.ApprovalRequestId, + decision.Decision, + decision.Comment, + decision.DecidedByUserId, + decision.DecidedByName, + decision.DecidedByEmail, + decision.DecidedByUserId.HasValue + ? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value) + : null, + decision.CreatedAt)) + .ToList())) + .ToList(); + + await SendOkAsync(dtos, ct); + } +} diff --git a/backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs new file mode 100644 index 0000000..fb482c8 --- /dev/null +++ b/backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -0,0 +1,169 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Approvals.Handlers; + +public record SubmitApprovalDecisionRequest( + string Decision, + string? Comment, + string? ReviewerName, + string? ReviewerEmail); + +public class SubmitApprovalDecisionRequestValidator + : Validator +{ + public SubmitApprovalDecisionRequestValidator() + { + RuleFor(x => x.Decision).NotEmpty().MaximumLength(64); + RuleFor(x => x.Comment).MaximumLength(2048); + RuleFor(x => x.ReviewerName).MaximumLength(256); + RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail)); + } +} + +public class SubmitApprovalDecisionHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/approvals/{id}/decisions"); + AllowAnonymous(); + Options(o => o.WithTags("Approvals")); + } + + public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (approval is null) + { + await SendNotFoundAsync(ct); + return; + } + + ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct); + if (contentItem is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (User?.Identity?.IsAuthenticated == true && + !accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + string normalizedDecision = request.Decision.Trim(); + string decidedByName = User?.Identity?.IsAuthenticated == true + ? User.GetAlias() ?? User.GetName() + : string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim(); + string decidedByEmail = User?.Identity?.IsAuthenticated == true + ? User.GetEmail() + : string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim(); + + ApprovalDecision decision = new() + { + Id = Guid.NewGuid(), + ApprovalRequestId = approval.Id, + Decision = normalizedDecision, + Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(), + DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null, + DecidedByName = decidedByName, + DecidedByEmail = decidedByEmail, + CreatedAt = DateTimeOffset.UtcNow, + }; + + approval.State = normalizedDecision; + approval.CompletedAt = DateTimeOffset.UtcNow; + + if (approval.Stage == "Internal") + { + contentItem.Status = normalizedDecision switch + { + "Approved" => "Ready for client review", + "Changes requested" => "Changes requested internally", + "Rejected" => "Rejected", + _ => contentItem.Status, + }; + } + else if (approval.Stage == "Client") + { + contentItem.Status = normalizedDecision switch + { + "Approved" => "Approved", + "Changes requested" => "Changes requested by client", + "Rejected" => "Rejected", + _ => contentItem.Status, + }; + } + + dbContext.ApprovalDecisions.Add(decision); + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.decision.recorded", + "ApprovalDecision", + decision.Id, + $"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.", + null, + decidedByEmail, + $$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""), + ct); + + List decisions = await dbContext.ApprovalDecisions + .Where(candidate => candidate.ApprovalRequestId == approval.Id) + .OrderByDescending(candidate => candidate.CreatedAt) + .ToListAsync(ct); + + List decidedByUserIds = decisions + .Where(candidate => candidate.DecidedByUserId.HasValue) + .Select(candidate => candidate.DecidedByUserId!.Value) + .Distinct() + .ToList(); + + Dictionary decisionPortraits = await dbContext.Users + .Where(user => decidedByUserIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct); + + List decisionDtos = decisions + .Select(candidate => new ApprovalDecisionDto( + candidate.Id, + candidate.ApprovalRequestId, + candidate.Decision, + candidate.Comment, + candidate.DecidedByUserId, + candidate.DecidedByName, + candidate.DecidedByEmail, + candidate.DecidedByUserId.HasValue + ? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value) + : null, + candidate.CreatedAt)) + .ToList(); + + ApprovalRequestDto dto = new( + approval.Id, + approval.WorkspaceId, + approval.ContentItemId, + approval.Stage, + approval.ReviewerName, + approval.ReviewerEmail, + approval.RequestedByUserId, + approval.DueAt, + approval.State, + approval.AccessToken, + approval.SentAt, + approval.CompletedAt, + decisionDtos); + + await SendOkAsync(dto, ct); + } +} diff --git a/backend/Modules/Assets/Data/Asset.cs b/backend/Modules/Assets/Data/Asset.cs new file mode 100644 index 0000000..5c4dc9f --- /dev/null +++ b/backend/Modules/Assets/Data/Asset.cs @@ -0,0 +1,16 @@ +namespace Socialize.Modules.Assets.Data; + +public class Asset +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ContentItemId { get; set; } + public required string AssetType { get; set; } + public required string SourceType { get; set; } + public required string DisplayName { get; set; } + public string? GoogleDriveFileId { get; set; } + public string? GoogleDriveLink { get; set; } + public string? PreviewUrl { get; set; } + public int CurrentRevisionNumber { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Assets/Data/AssetRevision.cs b/backend/Modules/Assets/Data/AssetRevision.cs new file mode 100644 index 0000000..1f9a7c6 --- /dev/null +++ b/backend/Modules/Assets/Data/AssetRevision.cs @@ -0,0 +1,13 @@ +namespace Socialize.Modules.Assets.Data; + +public class AssetRevision +{ + public Guid Id { get; init; } + public Guid AssetId { get; set; } + public int RevisionNumber { get; set; } + public required string SourceReference { get; set; } + public string? PreviewUrl { get; set; } + public string? Notes { get; set; } + public Guid? CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Assets/DependencyInjection.cs b/backend/Modules/Assets/DependencyInjection.cs new file mode 100644 index 0000000..d2c3392 --- /dev/null +++ b/backend/Modules/Assets/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.Assets.Data; + +namespace Socialize.Modules.Assets; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddAssetsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/Assets/Handlers/CreateAssetRevision.cs b/backend/Modules/Assets/Handlers/CreateAssetRevision.cs new file mode 100644 index 0000000..555f788 --- /dev/null +++ b/backend/Modules/Assets/Handlers/CreateAssetRevision.cs @@ -0,0 +1,102 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Assets.Handlers; + +public record CreateAssetRevisionRequest( + string SourceReference, + string? PreviewUrl, + string? Notes); + +public class CreateAssetRevisionRequestValidator + : Validator +{ + public CreateAssetRevisionRequestValidator() + { + RuleFor(x => x.SourceReference).NotEmpty().MaximumLength(2048); + RuleFor(x => x.PreviewUrl).MaximumLength(2048); + RuleFor(x => x.Notes).MaximumLength(1024); + } +} + +public class CreateAssetRevisionHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/assets/{id}/revisions"); + Options(o => o.WithTags("Assets")); + } + + public override async Task HandleAsync(CreateAssetRevisionRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (asset is null) + { + await SendNotFoundAsync(ct); + return; + } + + ContentItem? contentItem = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct); + + if (contentItem is not null && + !accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + int revisionNumber = asset.CurrentRevisionNumber + 1; + asset.CurrentRevisionNumber = revisionNumber; + asset.PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? asset.PreviewUrl : request.PreviewUrl.Trim(); + + AssetRevision revision = new() + { + Id = Guid.NewGuid(), + AssetId = asset.Id, + RevisionNumber = revisionNumber, + SourceReference = request.SourceReference.Trim(), + PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(), + Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + CreatedByUserId = User.GetUserId(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.AssetRevisions.Add(revision); + await dbContext.SaveChangesAsync(ct); + + if (contentItem is not null) + { + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + asset.WorkspaceId, + asset.ContentItemId, + "asset.revision.created", + "AssetRevision", + revision.Id, + $"A new asset revision was added to {asset.DisplayName}.", + User.GetUserId(), + User.GetEmail(), + $$"""{"revisionNumber":"{{revisionNumber}}"}"""), + ct); + } + + AssetRevisionDto dto = new( + revision.Id, + revision.AssetId, + revision.RevisionNumber, + revision.SourceReference, + revision.PreviewUrl, + revision.Notes, + revision.CreatedByUserId, + revision.CreatedAt); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs b/backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs new file mode 100644 index 0000000..7748eda --- /dev/null +++ b/backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs @@ -0,0 +1,130 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Assets.Handlers; + +public record CreateGoogleDriveAssetRequest( + Guid WorkspaceId, + Guid ContentItemId, + string AssetType, + string DisplayName, + string GoogleDriveFileId, + string GoogleDriveLink, + string? PreviewUrl); + +public class CreateGoogleDriveAssetRequestValidator + : Validator +{ + public CreateGoogleDriveAssetRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.ContentItemId).NotEmpty(); + RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64); + RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(256); + RuleFor(x => x.GoogleDriveFileId).NotEmpty().MaximumLength(256); + RuleFor(x => x.GoogleDriveLink).NotEmpty().MaximumLength(2048); + RuleFor(x => x.PreviewUrl).MaximumLength(2048); + } +} + +public class CreateGoogleDriveAssetHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/assets/google-drive"); + Options(o => o.WithTags("Assets")); + } + + public override async Task HandleAsync(CreateGoogleDriveAssetRequest request, CancellationToken ct) + { + ContentItem? contentItem = await dbContext.ContentItems + .SingleOrDefaultAsync( + candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, + ct); + + if (contentItem is null) + { + AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + Asset asset = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + ContentItemId = request.ContentItemId, + AssetType = request.AssetType.Trim(), + SourceType = "GoogleDrive", + DisplayName = request.DisplayName.Trim(), + GoogleDriveFileId = request.GoogleDriveFileId.Trim(), + GoogleDriveLink = request.GoogleDriveLink.Trim(), + PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(), + CurrentRevisionNumber = 1, + CreatedAt = DateTimeOffset.UtcNow, + }; + + AssetRevision revision = new() + { + Id = Guid.NewGuid(), + AssetId = asset.Id, + RevisionNumber = 1, + SourceReference = asset.GoogleDriveLink, + PreviewUrl = asset.PreviewUrl, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Assets.Add(asset); + dbContext.AssetRevisions.Add(revision); + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + asset.WorkspaceId, + asset.ContentItemId, + "asset.google-drive-linked", + "Asset", + asset.Id, + $"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.", + null, + null, + $$"""{"googleDriveFileId":"{{asset.GoogleDriveFileId}}"}"""), + ct); + + AssetDto dto = new( + asset.Id, + asset.WorkspaceId, + asset.ContentItemId, + asset.AssetType, + asset.SourceType, + asset.DisplayName, + asset.GoogleDriveFileId, + asset.GoogleDriveLink, + asset.PreviewUrl, + asset.CurrentRevisionNumber, + asset.CreatedAt, + [ + new AssetRevisionDto( + revision.Id, + revision.AssetId, + revision.RevisionNumber, + revision.SourceReference, + revision.PreviewUrl, + revision.Notes, + revision.CreatedByUserId, + revision.CreatedAt) + ]); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Assets/Handlers/GetAssets.cs b/backend/Modules/Assets/Handlers/GetAssets.cs new file mode 100644 index 0000000..41a7724 --- /dev/null +++ b/backend/Modules/Assets/Handlers/GetAssets.cs @@ -0,0 +1,89 @@ +using Socialize.Infrastructure.Security; +namespace Socialize.Modules.Assets.Handlers; + +public record GetAssetsRequest(Guid ContentItemId); + +public record AssetRevisionDto( + Guid Id, + Guid AssetId, + int RevisionNumber, + string SourceReference, + string? PreviewUrl, + string? Notes, + Guid? CreatedByUserId, + DateTimeOffset CreatedAt); + +public record AssetDto( + Guid Id, + Guid WorkspaceId, + Guid ContentItemId, + string AssetType, + string SourceType, + string DisplayName, + string? GoogleDriveFileId, + string? GoogleDriveLink, + string? PreviewUrl, + int CurrentRevisionNumber, + DateTimeOffset CreatedAt, + IReadOnlyCollection Revisions); + +public class GetAssetsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/assets"); + Options(o => o.WithTags("Assets")); + } + + public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct) + { + ContentItem? item = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + List assets = await dbContext.Assets + .Where(asset => asset.ContentItemId == request.ContentItemId) + .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.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(assets, ct); + } +} diff --git a/backend/Modules/Clients/Data/Client.cs b/backend/Modules/Clients/Data/Client.cs new file mode 100644 index 0000000..8209cc0 --- /dev/null +++ b/backend/Modules/Clients/Data/Client.cs @@ -0,0 +1,14 @@ +namespace Socialize.Modules.Clients.Data; + +public class Client +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public required string Name { get; set; } + public required string Status { get; set; } + public string? PortraitUrl { get; set; } + public string? PrimaryContactName { get; set; } + public string? PrimaryContactEmail { get; set; } + public string? PrimaryContactPortraitUrl { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Clients/DependencyInjection.cs b/backend/Modules/Clients/DependencyInjection.cs new file mode 100644 index 0000000..892d36b --- /dev/null +++ b/backend/Modules/Clients/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.Clients.Data; + +namespace Socialize.Modules.Clients; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddClientsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/Clients/Handlers/ChangeClientPortrait.cs b/backend/Modules/Clients/Handlers/ChangeClientPortrait.cs new file mode 100644 index 0000000..d35f39d --- /dev/null +++ b/backend/Modules/Clients/Handlers/ChangeClientPortrait.cs @@ -0,0 +1,65 @@ +using Socialize.Infrastructure.BlobStorage.Contracts; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Clients.Data; + +namespace Socialize.Modules.Clients.Handlers; + +public record ChangeClientPortraitRequest( + IFormFile File); + +public record ChangeClientPortraitResponse( + string BlobUrl); + +public sealed class ChangeClientPortraitRequestValidator : Validator +{ + public ChangeClientPortraitRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .NotEmpty(); + } +} + +public class ChangeClientPortraitHandler( + AppDbContext clientsDbContext, + IBlobStorage blobStorage, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/clients/{id}/portrait"); + Options(o => o.WithTags("Clients")); + AllowFileUploads(); + } + + public override async Task HandleAsync(ChangeClientPortraitRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (client is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + string blobUrl = await blobStorage.UploadFileAsync( + ContainerNames.Clients, + $"{client.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}", + request.File.OpenReadStream(), + request.File.ContentType, + ct); + + client.PortraitUrl = blobUrl; + await clientsDbContext.SaveChangesAsync(ct); + + await SendOkAsync(new ChangeClientPortraitResponse(blobUrl), ct); + } +} diff --git a/backend/Modules/Clients/Handlers/CreateClient.cs b/backend/Modules/Clients/Handlers/CreateClient.cs new file mode 100644 index 0000000..3b2b4e2 --- /dev/null +++ b/backend/Modules/Clients/Handlers/CreateClient.cs @@ -0,0 +1,101 @@ +using Socialize.Infrastructure.Security; +namespace Socialize.Modules.Clients.Handlers; + +public record CreateClientRequest( + Guid WorkspaceId, + string Name, + string? PortraitUrl, + string? PrimaryContactName, + string? PrimaryContactEmail, + string? PrimaryContactPortraitUrl); + +public class CreateClientRequestValidator + : Validator +{ + public CreateClientRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.PortraitUrl).MaximumLength(2048); + RuleFor(x => x.PrimaryContactName).MaximumLength(256); + RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail)); + RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048); + } +} + +public class CreateClientHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/clients"); + Options(o => o.WithTags("Clients")); + } + + public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct) + { + if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + bool workspaceExists = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct); + + if (!workspaceExists) + { + AddError(request => request.WorkspaceId, "The selected workspace does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + string normalizedName = request.Name.Trim(); + string? normalizedPortraitUrl = request.PortraitUrl?.Trim(); + string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim(); + string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim(); + string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim(); + + bool duplicateClient = await dbContext.Clients + .AnyAsync( + client => client.WorkspaceId == request.WorkspaceId && client.Name == normalizedName, + ct); + + if (duplicateClient) + { + AddError(request => request.Name, "A client with this name already exists in the active workspace."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + Client client = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + Name = normalizedName, + Status = "Active", + PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl, + PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName, + PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail, + PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Clients.Add(client); + await dbContext.SaveChangesAsync(ct); + + ClientDto dto = new( + client.Id, + client.WorkspaceId, + client.Name, + client.Status, + client.PortraitUrl, + client.PrimaryContactName, + client.PrimaryContactEmail, + client.PrimaryContactPortraitUrl); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Clients/Handlers/GetClients.cs b/backend/Modules/Clients/Handlers/GetClients.cs new file mode 100644 index 0000000..231b8d2 --- /dev/null +++ b/backend/Modules/Clients/Handlers/GetClients.cs @@ -0,0 +1,73 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Clients.Data; + +namespace Socialize.Modules.Clients.Handlers; + +public record GetClientsRequest(Guid? WorkspaceId); + +public record ClientDto( + Guid Id, + Guid WorkspaceId, + string Name, + string Status, + string? PortraitUrl, + string? PrimaryContactName, + string? PrimaryContactEmail, + string? PrimaryContactPortraitUrl); + +public class GetClientsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/clients"); + Options(o => o.WithTags("Clients")); + } + + public override async Task HandleAsync(GetClientsRequest request, CancellationToken ct) + { + IQueryable query = dbContext.Clients.AsQueryable(); + + if (accessScopeService.IsManager(User)) + { + if (request.WorkspaceId.HasValue) + { + query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); + } + } + else + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); + + query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId)); + + if (clientScopeIds.Count > 0) + { + query = query.Where(client => clientScopeIds.Contains(client.Id)); + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); + } + } + + List clients = await query + .OrderBy(client => client.Name) + .Select(client => new ClientDto( + client.Id, + client.WorkspaceId, + client.Name, + client.Status, + client.PortraitUrl, + client.PrimaryContactName, + client.PrimaryContactEmail, + client.PrimaryContactPortraitUrl)) + .ToListAsync(ct); + + await SendOkAsync(clients, ct); + } +} diff --git a/backend/Modules/Clients/Handlers/UpdateClient.cs b/backend/Modules/Clients/Handlers/UpdateClient.cs new file mode 100644 index 0000000..e11672a --- /dev/null +++ b/backend/Modules/Clients/Handlers/UpdateClient.cs @@ -0,0 +1,98 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Clients.Data; + +namespace Socialize.Modules.Clients.Handlers; + +public record UpdateClientRequest( + string Name, + string? PortraitUrl, + string Status, + string? PrimaryContactName, + string? PrimaryContactEmail, + string? PrimaryContactPortraitUrl); + +public class UpdateClientRequestValidator + : Validator +{ + public UpdateClientRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.PortraitUrl).MaximumLength(2048); + RuleFor(x => x.Status).NotEmpty().MaximumLength(64); + RuleFor(x => x.PrimaryContactName).MaximumLength(256); + RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail)); + RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048); + } +} + +public class UpdateClientHandler( + AppDbContext clientsDbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Put("/api/clients/{id}"); + Options(o => o.WithTags("Clients")); + } + + public override async Task HandleAsync(UpdateClientRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (client is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + string normalizedName = request.Name.Trim(); + string normalizedStatus = request.Status.Trim(); + string? normalizedPortraitUrl = request.PortraitUrl?.Trim(); + string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim(); + string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim(); + string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim(); + + bool duplicateClient = await clientsDbContext.Clients + .AnyAsync( + candidate => candidate.Id != id + && candidate.WorkspaceId == client.WorkspaceId + && candidate.Name == normalizedName, + ct); + + if (duplicateClient) + { + AddError(request => request.Name, "A client with this name already exists in the active workspace."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + client.Name = normalizedName; + client.Status = normalizedStatus; + client.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl; + client.PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName; + client.PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail; + client.PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl; + + await clientsDbContext.SaveChangesAsync(ct); + + ClientDto dto = new( + client.Id, + client.WorkspaceId, + client.Name, + client.Status, + client.PortraitUrl, + client.PrimaryContactName, + client.PrimaryContactEmail, + client.PrimaryContactPortraitUrl); + + await SendOkAsync(dto, ct); + } +} diff --git a/backend/Modules/Comments/Data/Comment.cs b/backend/Modules/Comments/Data/Comment.cs new file mode 100644 index 0000000..a392f5d --- /dev/null +++ b/backend/Modules/Comments/Data/Comment.cs @@ -0,0 +1,16 @@ +namespace Socialize.Modules.Comments.Data; + +public class Comment +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ContentItemId { get; set; } + public Guid? ParentCommentId { get; set; } + public Guid AuthorUserId { get; set; } + public required string AuthorDisplayName { get; set; } + public required string AuthorEmail { get; set; } + public required string Body { get; set; } + public bool IsResolved { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? ResolvedAt { get; set; } +} diff --git a/backend/Modules/Comments/DependencyInjection.cs b/backend/Modules/Comments/DependencyInjection.cs new file mode 100644 index 0000000..5f9148e --- /dev/null +++ b/backend/Modules/Comments/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.Comments.Data; + +namespace Socialize.Modules.Comments; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddCommentsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/Comments/Handlers/CreateComment.cs b/backend/Modules/Comments/Handlers/CreateComment.cs new file mode 100644 index 0000000..36cbc3d --- /dev/null +++ b/backend/Modules/Comments/Handlers/CreateComment.cs @@ -0,0 +1,120 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Comments.Handlers; + +public record CreateCommentRequest( + Guid WorkspaceId, + Guid ContentItemId, + Guid? ParentCommentId, + string Body); + +public class CreateCommentRequestValidator + : Validator +{ + public CreateCommentRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.ContentItemId).NotEmpty(); + RuleFor(x => x.Body).NotEmpty().MaximumLength(4000); + } +} + +public class CreateCommentHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/comments"); + Options(o => o.WithTags("Comments")); + } + + public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct) + { + ContentItem? contentItem = await dbContext.ContentItems + .SingleOrDefaultAsync( + candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, + ct); + + if (contentItem is null) + { + AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + if (request.ParentCommentId.HasValue) + { + bool parentExists = await dbContext.Comments + .AnyAsync( + comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId, + ct); + + if (!parentExists) + { + AddError(request => request.ParentCommentId, "The selected parent comment does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + } + + Comment comment = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + ContentItemId = request.ContentItemId, + ParentCommentId = request.ParentCommentId, + AuthorUserId = User.GetUserId(), + AuthorDisplayName = User.GetAlias() ?? User.GetName(), + AuthorEmail = User.GetEmail(), + Body = request.Body.Trim(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Comments.Add(comment); + await dbContext.SaveChangesAsync(ct); + + string? authorPortraitUrl = await dbContext.Users + .Where(candidate => candidate.Id == comment.AuthorUserId) + .Select(candidate => candidate.PortraitUrl) + .SingleOrDefaultAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + comment.WorkspaceId, + comment.ContentItemId, + "comment.created", + "Comment", + comment.Id, + $"{comment.AuthorDisplayName} commented on {contentItem.Title}.", + null, + null, + $$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""), + ct); + + CommentDto dto = new( + comment.Id, + comment.WorkspaceId, + comment.ContentItemId, + comment.ParentCommentId, + comment.AuthorUserId, + comment.AuthorDisplayName, + comment.AuthorEmail, + authorPortraitUrl, + comment.Body, + comment.IsResolved, + comment.CreatedAt, + comment.ResolvedAt); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Comments/Handlers/GetComments.cs b/backend/Modules/Comments/Handlers/GetComments.cs new file mode 100644 index 0000000..a1eed84 --- /dev/null +++ b/backend/Modules/Comments/Handlers/GetComments.cs @@ -0,0 +1,80 @@ +using Socialize.Infrastructure.Security; + +namespace Socialize.Modules.Comments.Handlers; + +public record GetCommentsRequest(Guid ContentItemId); + +public record CommentDto( + Guid Id, + Guid WorkspaceId, + Guid ContentItemId, + Guid? ParentCommentId, + Guid AuthorUserId, + string AuthorDisplayName, + string AuthorEmail, + string? AuthorPortraitUrl, + string Body, + bool IsResolved, + DateTimeOffset CreatedAt, + DateTimeOffset? ResolvedAt); + +public class GetCommentsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/comments"); + Options(o => o.WithTags("Comments")); + } + + public override async Task HandleAsync(GetCommentsRequest request, CancellationToken ct) + { + ContentItem? item = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + List comments = await dbContext.Comments + .Where(comment => comment.ContentItemId == request.ContentItemId) + .OrderBy(comment => comment.CreatedAt) + .ToListAsync(ct); + + List authorIds = comments + .Select(comment => comment.AuthorUserId) + .Distinct() + .ToList(); + + Dictionary authorPortraits = await dbContext.Users + .Where(user => authorIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct); + + List dtos = comments + .Select(comment => new CommentDto( + comment.Id, + comment.WorkspaceId, + comment.ContentItemId, + comment.ParentCommentId, + comment.AuthorUserId, + comment.AuthorDisplayName, + comment.AuthorEmail, + authorPortraits.GetValueOrDefault(comment.AuthorUserId), + comment.Body, + comment.IsResolved, + comment.CreatedAt, + comment.ResolvedAt)) + .ToList(); + + await SendOkAsync(dtos, ct); + } +} diff --git a/backend/Modules/Comments/Handlers/ResolveComment.cs b/backend/Modules/Comments/Handlers/ResolveComment.cs new file mode 100644 index 0000000..cd800a9 --- /dev/null +++ b/backend/Modules/Comments/Handlers/ResolveComment.cs @@ -0,0 +1,84 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.Comments.Handlers; + +public class ResolveCommentHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/comments/{id}/resolve"); + Options(o => o.WithTags("Comments")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + + Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (comment is null) + { + await SendNotFoundAsync(ct); + return; + } + + ContentItem? contentItem = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct); + if (contentItem is null) + { + await SendNotFoundAsync(ct); + return; + } + + bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId) + || accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId); + + if (!canResolve) + { + await SendForbiddenAsync(ct); + return; + } + + comment.IsResolved = true; + comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(ct); + + string? authorPortraitUrl = await dbContext.Users + .Where(candidate => candidate.Id == comment.AuthorUserId) + .Select(candidate => candidate.PortraitUrl) + .SingleOrDefaultAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + comment.WorkspaceId, + comment.ContentItemId, + "comment.resolved", + "Comment", + comment.Id, + $"{User.GetAlias() ?? User.GetName()} resolved a comment.", + null, + null, + null), + ct); + + CommentDto dto = new( + comment.Id, + comment.WorkspaceId, + comment.ContentItemId, + comment.ParentCommentId, + comment.AuthorUserId, + comment.AuthorDisplayName, + comment.AuthorEmail, + authorPortraitUrl, + comment.Body, + comment.IsResolved, + comment.CreatedAt, + comment.ResolvedAt); + + await SendOkAsync(dto, ct); + } +} diff --git a/backend/Modules/ContentItems/Data/ContentItem.cs b/backend/Modules/ContentItems/Data/ContentItem.cs new file mode 100644 index 0000000..3166fcf --- /dev/null +++ b/backend/Modules/ContentItems/Data/ContentItem.cs @@ -0,0 +1,18 @@ +namespace Socialize.Modules.ContentItems.Data; + +public class ContentItem +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ClientId { get; set; } + public Guid ProjectId { get; set; } + public required string Title { get; set; } + public required string PublicationMessage { get; set; } + public required string PublicationTargets { get; set; } + public string? Hashtags { get; set; } + public required string Status { get; set; } + public DateTimeOffset? DueDate { get; set; } + public required string CurrentRevisionLabel { get; set; } + public int CurrentRevisionNumber { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/ContentItems/Data/ContentItemRevision.cs b/backend/Modules/ContentItems/Data/ContentItemRevision.cs new file mode 100644 index 0000000..f0a098c --- /dev/null +++ b/backend/Modules/ContentItems/Data/ContentItemRevision.cs @@ -0,0 +1,16 @@ +namespace Socialize.Modules.ContentItems.Data; + +public class ContentItemRevision +{ + public Guid Id { get; init; } + public Guid ContentItemId { get; set; } + public int RevisionNumber { get; set; } + public required string RevisionLabel { get; set; } + public required string Title { get; set; } + public required string PublicationMessage { get; set; } + public required string PublicationTargets { get; set; } + public string? Hashtags { get; set; } + public string? ChangeSummary { get; set; } + public Guid? CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/ContentItems/DependencyInjection.cs b/backend/Modules/ContentItems/DependencyInjection.cs new file mode 100644 index 0000000..50ea2e8 --- /dev/null +++ b/backend/Modules/ContentItems/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.ContentItems.Data; + +namespace Socialize.Modules.ContentItems; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddContentItemsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/ContentItems/Handlers/CreateContentItem.cs b/backend/Modules/ContentItems/Handlers/CreateContentItem.cs new file mode 100644 index 0000000..fb89fda --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/CreateContentItem.cs @@ -0,0 +1,148 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.ContentItems.Handlers; + +public record CreateContentItemRequest( + Guid WorkspaceId, + Guid ClientId, + Guid ProjectId, + string Title, + string PublicationMessage, + string PublicationTargets, + string? Hashtags, + DateTimeOffset? DueDate); + +public class CreateContentItemRequestValidator + : Validator +{ + public CreateContentItemRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.ClientId).NotEmpty(); + RuleFor(x => x.ProjectId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(256); + RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000); + RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512); + RuleFor(x => x.Hashtags).MaximumLength(1024); + } +} + +public class CreateContentItemHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/content-items"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct) + { + if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + bool workspaceExists = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct); + + if (!workspaceExists) + { + AddError(request => request.WorkspaceId, "The selected workspace does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + bool clientExists = await dbContext.Clients + .AnyAsync( + client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId, + ct); + + if (!clientExists) + { + AddError(request => request.ClientId, "The selected client does not belong to the active workspace."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + bool projectExists = await dbContext.Projects + .AnyAsync( + project => project.Id == request.ProjectId && + project.WorkspaceId == request.WorkspaceId && + project.ClientId == request.ClientId, + ct); + + if (!projectExists) + { + AddError(request => request.ProjectId, "The selected project does not belong to the selected client."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + ContentItem item = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + ClientId = request.ClientId, + ProjectId = request.ProjectId, + Title = request.Title.Trim(), + PublicationMessage = request.PublicationMessage.Trim(), + PublicationTargets = request.PublicationTargets.Trim(), + Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(), + Status = "Draft", + DueDate = request.DueDate, + CurrentRevisionLabel = "v1", + CurrentRevisionNumber = 1, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.ContentItems.Add(item); + dbContext.ContentItemRevisions.Add(new ContentItemRevision + { + Id = Guid.NewGuid(), + ContentItemId = item.Id, + RevisionNumber = 1, + RevisionLabel = "v1", + Title = item.Title, + PublicationMessage = item.PublicationMessage, + PublicationTargets = item.PublicationTargets, + Hashtags = item.Hashtags, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + item.WorkspaceId, + item.Id, + "content-item.created", + "ContentItem", + item.Id, + $"Content item {item.Title} was created.", + null, + null, + $$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""), + ct); + + ContentItemDto dto = new( + item.Id, + item.WorkspaceId, + item.ClientId, + item.ProjectId, + item.Title, + item.PublicationMessage, + item.PublicationTargets, + item.Hashtags, + item.Status, + item.DueDate, + item.CurrentRevisionLabel, + item.CurrentRevisionNumber); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/ContentItems/Handlers/CreateContentItemRevision.cs b/backend/Modules/ContentItems/Handlers/CreateContentItemRevision.cs new file mode 100644 index 0000000..41a8569 --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/CreateContentItemRevision.cs @@ -0,0 +1,120 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.ContentItems.Handlers; + +public record CreateContentItemRevisionRequest( + string Title, + string PublicationMessage, + string PublicationTargets, + string? Hashtags, + string? ChangeSummary); + +public class CreateContentItemRevisionRequestValidator + : Validator +{ + public CreateContentItemRevisionRequestValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(256); + RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000); + RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512); + RuleFor(x => x.Hashtags).MaximumLength(1024); + RuleFor(x => x.ChangeSummary).MaximumLength(1024); + } +} + +public class CreateContentItemRevisionHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + public override void Configure() + { + Post("/api/content-items/{id}/revisions"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(CreateContentItemRevisionRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + int revisionNumber = item.CurrentRevisionNumber + 1; + string revisionLabel = $"v{revisionNumber}"; + + item.Title = request.Title.Trim(); + item.PublicationMessage = request.PublicationMessage.Trim(); + item.PublicationTargets = request.PublicationTargets.Trim(); + item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(); + item.CurrentRevisionNumber = revisionNumber; + item.CurrentRevisionLabel = revisionLabel; + + if (item.Status == "Changes requested internally") + { + item.Status = "Internal changes in progress"; + } + else if (item.Status == "Changes requested by client") + { + item.Status = "Client changes in progress"; + } + + ContentItemRevision revision = new() + { + Id = Guid.NewGuid(), + ContentItemId = item.Id, + RevisionNumber = revisionNumber, + RevisionLabel = revisionLabel, + Title = item.Title, + PublicationMessage = item.PublicationMessage, + PublicationTargets = item.PublicationTargets, + Hashtags = item.Hashtags, + ChangeSummary = string.IsNullOrWhiteSpace(request.ChangeSummary) ? null : request.ChangeSummary.Trim(), + CreatedByUserId = User.GetUserId(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.ContentItemRevisions.Add(revision); + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + item.WorkspaceId, + item.Id, + "content-item.revision.created", + "ContentItemRevision", + revision.Id, + $"Revision {revisionLabel} was created for {item.Title}.", + User.GetUserId(), + User.GetEmail(), + $$"""{"revisionLabel":"{{revisionLabel}}","status":"{{item.Status}}"}"""), + ct); + + ContentItemRevisionDto dto = new( + revision.Id, + revision.ContentItemId, + revision.RevisionNumber, + revision.RevisionLabel, + revision.Title, + revision.PublicationMessage, + revision.PublicationTargets, + revision.Hashtags, + revision.ChangeSummary, + revision.CreatedByUserId, + revision.CreatedAt); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/ContentItems/Handlers/GetContentItem.cs b/backend/Modules/ContentItems/Handlers/GetContentItem.cs new file mode 100644 index 0000000..83d46eb --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/GetContentItem.cs @@ -0,0 +1,68 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.ContentItems.Data; + +namespace Socialize.Modules.ContentItems.Handlers; + +public record ContentItemDetailDto( + Guid Id, + Guid WorkspaceId, + Guid ClientId, + Guid ProjectId, + string Title, + string PublicationMessage, + string PublicationTargets, + string? Hashtags, + string Status, + DateTimeOffset? DueDate, + string CurrentRevisionLabel, + int CurrentRevisionNumber, + DateTimeOffset CreatedAt); + +public class GetContentItemHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/content-items/{id}"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + + ContentItemDetailDto? item = await dbContext.ContentItems + .Where(candidate => candidate.Id == id) + .Select(candidate => new ContentItemDetailDto( + candidate.Id, + candidate.WorkspaceId, + candidate.ClientId, + candidate.ProjectId, + candidate.Title, + candidate.PublicationMessage, + candidate.PublicationTargets, + candidate.Hashtags, + candidate.Status, + candidate.DueDate, + candidate.CurrentRevisionLabel, + candidate.CurrentRevisionNumber, + candidate.CreatedAt)) + .SingleOrDefaultAsync(ct); + + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + await SendOkAsync(item, ct); + } +} diff --git a/backend/Modules/ContentItems/Handlers/GetContentItemRevisions.cs b/backend/Modules/ContentItems/Handlers/GetContentItemRevisions.cs new file mode 100644 index 0000000..2a148cc --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/GetContentItemRevisions.cs @@ -0,0 +1,64 @@ +using Socialize.Infrastructure.Security; +namespace Socialize.Modules.ContentItems.Handlers; + +public record ContentItemRevisionDto( + Guid Id, + Guid ContentItemId, + int RevisionNumber, + string RevisionLabel, + string Title, + string PublicationMessage, + string PublicationTargets, + string? Hashtags, + string? ChangeSummary, + Guid? CreatedByUserId, + DateTimeOffset CreatedAt); + +public class GetContentItemRevisionsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/content-items/{id}/revisions"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + + ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + + List revisions = await dbContext.ContentItemRevisions + .Where(revision => revision.ContentItemId == id) + .OrderByDescending(revision => revision.RevisionNumber) + .Select(revision => new ContentItemRevisionDto( + revision.Id, + revision.ContentItemId, + revision.RevisionNumber, + revision.RevisionLabel, + revision.Title, + revision.PublicationMessage, + revision.PublicationTargets, + revision.Hashtags, + revision.ChangeSummary, + revision.CreatedByUserId, + revision.CreatedAt)) + .ToListAsync(ct); + + await SendOkAsync(revisions, ct); + } +} diff --git a/backend/Modules/ContentItems/Handlers/GetContentItems.cs b/backend/Modules/ContentItems/Handlers/GetContentItems.cs new file mode 100644 index 0000000..1326561 --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/GetContentItems.cs @@ -0,0 +1,91 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.ContentItems.Data; + +namespace Socialize.Modules.ContentItems.Handlers; + +public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId); + +public record ContentItemDto( + Guid Id, + Guid WorkspaceId, + Guid ClientId, + Guid ProjectId, + string Title, + string PublicationMessage, + string PublicationTargets, + string? Hashtags, + string Status, + DateTimeOffset? DueDate, + string CurrentRevisionLabel, + int CurrentRevisionNumber); + +public class GetContentItemsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/content-items"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct) + { + IQueryable query = dbContext.ContentItems.AsQueryable(); + + if (!accessScopeService.IsManager(User)) + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); + IReadOnlyCollection projectScopeIds = User.GetProjectScopeIds(); + + query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId)); + + if (clientScopeIds.Count > 0) + { + query = query.Where(item => clientScopeIds.Contains(item.ClientId)); + } + + if (projectScopeIds.Count > 0) + { + query = query.Where(item => projectScopeIds.Contains(item.ProjectId)); + } + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value); + } + + if (request.ProjectId.HasValue) + { + query = query.Where(item => item.ProjectId == request.ProjectId.Value); + } + + if (request.ClientId.HasValue) + { + query = query.Where(item => item.ClientId == request.ClientId.Value); + } + + List items = await query + .OrderBy(item => item.DueDate) + .ThenBy(item => item.Title) + .Select(item => new ContentItemDto( + item.Id, + item.WorkspaceId, + item.ClientId, + item.ProjectId, + item.Title, + item.PublicationMessage, + item.PublicationTargets, + item.Hashtags, + item.Status, + item.DueDate, + item.CurrentRevisionLabel, + item.CurrentRevisionNumber)) + .ToListAsync(ct); + + await SendOkAsync(items, ct); + } +} diff --git a/backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs b/backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs new file mode 100644 index 0000000..28ba497 --- /dev/null +++ b/backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs @@ -0,0 +1,105 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.ContentItems.Data; +using Socialize.Modules.Notifications.Contracts; + +namespace Socialize.Modules.ContentItems.Handlers; + +public record UpdateContentItemStatusRequest(string Status); + +public class UpdateContentItemStatusRequestValidator + : Validator +{ + public UpdateContentItemStatusRequestValidator() + { + RuleFor(x => x.Status).NotEmpty().MaximumLength(64); + } +} + +public class UpdateContentItemStatusHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + INotificationEventWriter notificationEventWriter) + : Endpoint +{ + private static readonly HashSet AllowedStatuses = + [ + "Draft", + "In internal review", + "Changes requested internally", + "Internal changes in progress", + "Ready for client review", + "In client review", + "Changes requested by client", + "Client changes in progress", + "Approved", + "Rejected", + "Ready to publish", + "Published", + "Archived", + ]; + + public override void Configure() + { + Post("/api/content-items/{id}/status"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct) + { + Guid id = Route("id"); + + ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + string normalizedStatus = request.Status.Trim(); + if (!AllowedStatuses.Contains(normalizedStatus)) + { + AddError(request => request.Status, "The requested status is not valid."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + item.Status = normalizedStatus; + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + item.WorkspaceId, + item.Id, + "content-item.status.updated", + "ContentItem", + item.Id, + $"Status changed to {item.Status} for {item.Title}.", + User.GetUserId(), + User.GetEmail(), + $$"""{"status":"{{item.Status}}"}"""), + ct); + + ContentItemDetailDto dto = new( + item.Id, + item.WorkspaceId, + item.ClientId, + item.ProjectId, + item.Title, + item.PublicationMessage, + item.PublicationTargets, + item.Hashtags, + item.Status, + item.DueDate, + item.CurrentRevisionLabel, + item.CurrentRevisionNumber, + item.CreatedAt); + + await SendOkAsync(dto, ct); + } +} diff --git a/backend/Modules/Contents/Data/Album.cs b/backend/Modules/Contents/Data/Album.cs deleted file mode 100644 index 8785663..0000000 --- a/backend/Modules/Contents/Data/Album.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Hutopy.Common.Domain; - -namespace Hutopy.Modules.Contents.Data; - -public class Album : Entity -{ - public bool IsDeleted { get; private set; } // private set → EF updates it - [MaxLength(255)] public required string Title { get; set; } - public IList Photos { get; set; } = new List(); -} diff --git a/backend/Modules/Contents/Data/AlbumPhoto.cs b/backend/Modules/Contents/Data/AlbumPhoto.cs deleted file mode 100644 index 70b2454..0000000 --- a/backend/Modules/Contents/Data/AlbumPhoto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Hutopy.Common.Domain; - -namespace Hutopy.Modules.Contents.Data; - -public class AlbumPhoto : Entity -{ - public bool IsDeleted { get; private set; } // private set → EF updates it - public Guid AlbumId { get; set; } - public Album Album { get; init; } = null!; - [MaxLength(2048)] public required string OriginalUrl { get; set; } - [MaxLength(2048)] public required string ThumbnailUrl { get; set; } - [MaxLength(256)] public string? Caption { get; set; } - public int Order { get; set; } -} diff --git a/backend/Modules/Contents/Data/ContentsDbContext.cs b/backend/Modules/Contents/Data/ContentsDbContext.cs deleted file mode 100644 index 08ee5d3..0000000 --- a/backend/Modules/Contents/Data/ContentsDbContext.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Hutopy.Modules.Contents.Data; - -public class ContentsDbContext( - DbContextOptions options) - : DbContext(options) -{ - public const string SchemaName = "Content"; - - public DbSet Albums => Set(); - public DbSet AlbumPhotos => Set(); - - protected override void OnModelCreating( - ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - // Album configuration - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - modelBuilder - .Entity() - .Property(c => c.IsDeleted) - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - modelBuilder - .Entity() - .HasQueryFilter(a => !a.IsDeleted); - - // AlbumPhoto configuration - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - modelBuilder - .Entity() - .Property(c => c.IsDeleted) - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - modelBuilder - .Entity() - .HasOne(ap => ap.Album) - .WithMany(a => a.Photos) - .HasForeignKey(ap => ap.AlbumId) - .IsRequired(); - - modelBuilder - .Entity() - .HasQueryFilter(ap => !ap.IsDeleted); - } -} diff --git a/backend/Modules/Contents/DependencyInjection.cs b/backend/Modules/Contents/DependencyInjection.cs deleted file mode 100644 index ee2a51e..0000000 --- a/backend/Modules/Contents/DependencyInjection.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Hutopy.Modules.Contents.Data; - -namespace Hutopy.Modules.Contents; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddContentModule( - this WebApplicationBuilder builder, - Action? configureAction = null) - { - builder.Services.AddDbContext(configureAction); - - return builder; - } - - public static async Task UseContentModuleAsync( - this IApplicationBuilder app, - CancellationToken cancellationToken = default) - { - IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); - using IServiceScope scope = scopeFactory.CreateScope(); - await using ContentsDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - - return app; - } -} diff --git a/backend/Modules/Contents/Features/AddPhotoToAlbum.cs b/backend/Modules/Contents/Features/AddPhotoToAlbum.cs deleted file mode 100644 index 80cf6d5..0000000 --- a/backend/Modules/Contents/Features/AddPhotoToAlbum.cs +++ /dev/null @@ -1,195 +0,0 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Contents.Data; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; - -namespace Hutopy.Modules.Contents.Features; - -[PublicAPI] -public record AddPhotoToAlbumRequest( - Guid AlbumId, - Guid PhotoId, - IFormFile File, - string? Caption = null); - -[PublicAPI] -public record AddPhotoToAlbumResponse( - Guid PhotoId, - string OriginalUrl, - string ThumbnailUrl); - -[PublicAPI] -public sealed class AddPhotoToAlbumRequestValidator : Validator -{ - private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB - - private static readonly string[] AllowedImageTypes = - [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ]; - - public AddPhotoToAlbumRequestValidator() - { - RuleFor(x => x.AlbumId) - .NotNull() - .NotEmpty(); - - RuleFor(x => x.PhotoId) - .NotNull() - .NotEmpty(); - - RuleFor(x => x.File) - .NotNull() - .NotEmpty() - .Must(file => AllowedImageTypes.Contains(file.ContentType)) - .WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)") - .Must(file => file.Length <= MaxFileSizeBytes) - .WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB"); - - RuleFor(x => x.Caption) - .MaximumLength(255); - } -} - -[PublicAPI] -public class AddPhotoToAlbumHandler( - ContentsDbContext context, - IBlobStorage blobStorage) - : Endpoint -{ - private const int MaxThumbnailWidth = 500; - private const int MaxThumbnailHeight = 500; - - public override void Configure() - { - Post("/api/albums/{AlbumId}/photos"); - Options(o => o.WithTags("Albums")); - AllowFileUploads(); - } - - public override async Task HandleAsync( - AddPhotoToAlbumRequest request, - CancellationToken ct) - { - Guid userId = User.GetUserId(); - - // Fetch the album we want to add photos to - Album? album = await context - .Albums - .SingleOrDefaultAsync( - a => a.Id == request.AlbumId && a.CreatedBy == userId, - ct); - - if (album is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Check if a photo with the same ID already exists - bool existingPhoto = await context - .AlbumPhotos - .AnyAsync(p => p.Id == request.PhotoId, ct); - - if (existingPhoto) - { - await SendErrorsAsync(409, ct); - return; - } - - try - { - (string originalUrl, string thumbnailUrl) = await ProcessAndUploadImage(request, ct); - - // Get the next order number - int nextOrder = await context - .AlbumPhotos - .Where(p => p.AlbumId == request.AlbumId) - .MaxAsync(p => (int?)p.Order, ct) ?? 0; - - // Create the album photo - AlbumPhoto photo = new() - { - Id = request.PhotoId, - CreatedBy = userId, - AlbumId = request.AlbumId, - OriginalUrl = originalUrl, - ThumbnailUrl = thumbnailUrl, - Caption = request.Caption, - Order = nextOrder + 1 - }; - - context.AlbumPhotos.Add(photo); - await context.SaveChangesAsync(ct); - - await SendOkAsync( - new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl), - ct); - } - catch (UnknownImageFormatException) - { - await SendStringAsync("Invalid image format", 400, cancellation: ct); - } - catch (Exception) - { - await SendStringAsync("Error processing image", 500, cancellation: ct); - } - } - - private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage( - AddPhotoToAlbumRequest request, - CancellationToken ct) - { - string originalFileName = Path.GetFileName(request.File.FileName); - string nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName); - string extension = Path.GetExtension(originalFileName); - - string filenameOriginal = $"{nameWithoutExt}{extension}"; - string filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}"; - - string blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}"; - string blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}"; - - // Process the original image - await using Stream originalStream = request.File.OpenReadStream(); - using Image image = await Image.LoadAsync(originalStream, ct); - - // Calculate target size while preserving the original aspect ratio - int originalWidth = image.Width; - int originalHeight = image.Height; - - double ratioX = (double)MaxThumbnailWidth / originalWidth; - double ratioY = (double)MaxThumbnailHeight / originalHeight; - double ratio = Math.Min(ratioX, ratioY); - - int newWidth = (int)(originalWidth * ratio); - int newHeight = (int)(originalHeight * ratio); - - // Create thumbnail - using MemoryStream thumbnailStream = new(); - image.Mutate(x => x.Resize(newWidth, newHeight)); - await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct); - thumbnailStream.Position = 0; - - // Upload both versions - string originalUrl = await blobStorage.UploadFileAsync( - ContainerNames.Creators, - blobOriginal, - request.File.OpenReadStream(), - request.File.ContentType, - ct); - - string thumbnailUrl = await blobStorage.UploadFileAsync( - ContainerNames.Creators, - blobThumbnail, - thumbnailStream, - request.File.ContentType, - ct); - - return (originalUrl, thumbnailUrl); - } -} diff --git a/backend/Modules/Contents/Features/CreateAlbum.cs b/backend/Modules/Contents/Features/CreateAlbum.cs deleted file mode 100644 index 9fa923a..0000000 --- a/backend/Modules/Contents/Features/CreateAlbum.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Contents.Data; - -namespace Hutopy.Modules.Contents.Features; - -[PublicAPI] -public record CreateAlbumRequest( - Guid AlbumId, - string Title, - string? Description = null); - -[PublicAPI] -public record CreateAlbumResponse( - Guid AlbumId); - -[PublicAPI] -public sealed class CreateAlbumRequestValidator : Validator -{ - public CreateAlbumRequestValidator() - { - RuleFor(x => x.AlbumId) - .NotNull() - .NotEmpty(); - - RuleFor(x => x.Title) - .NotNull() - .NotEmpty() - .MaximumLength(255); - - RuleFor(x => x.Description) - .MaximumLength(1000); - } -} - -[PublicAPI] -public class CreateAlbumHandler( - ContentsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/albums"); - Options(o => o.WithTags("Albums")); - } - - public override async Task HandleAsync( - CreateAlbumRequest request, - CancellationToken ct) - { - // Check if an album with the same ID already exists - bool existingAlbum = await context - .Albums - .AnyAsync(a => a.Id == request.AlbumId, ct); - - if (existingAlbum) - { - await SendErrorsAsync(409, ct); - return; - } - - Album album = new() { Id = request.AlbumId, CreatedBy = User.GetUserId(), Title = request.Title }; - - context.Albums.Add(album); - await context.SaveChangesAsync(ct); - - await SendOkAsync( - new CreateAlbumResponse(album.Id), - ct); - } -} diff --git a/backend/Modules/Contents/Features/GetAlbum.cs b/backend/Modules/Contents/Features/GetAlbum.cs deleted file mode 100644 index e2ea44e..0000000 --- a/backend/Modules/Contents/Features/GetAlbum.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Hutopy.Modules.Contents.Data; - -namespace Hutopy.Modules.Contents.Features; - -[PublicAPI] -public record GetAlbumRequest( - Guid AlbumId); - -[PublicAPI] -public record AlbumPhotoDto( - Guid Id, - string OriginalUrl, - string ThumbnailUrl, - string? Caption, - int Order, - DateTimeOffset CreatedAt); - -[PublicAPI] -public record GetAlbumResponse( - Guid Id, - string Title, - IReadOnlyList Photos, - DateTimeOffset CreatedAt); - -[PublicAPI] -public sealed class GetAlbumRequestValidator : Validator -{ - public GetAlbumRequestValidator() - { - RuleFor(x => x.AlbumId) - .NotNull() - .NotEmpty(); - } -} - -[PublicAPI] -public class GetAlbumHandler( - ContentsDbContext context) - : Endpoint -{ - public override void Configure() - { - AllowAnonymous(); - Get("/api/albums/{AlbumId}"); - Options(o => o.WithTags("Albums")); - } - - public override async Task HandleAsync( - GetAlbumRequest request, - CancellationToken ct) - { - Album? album = await context - .Albums - .Include(a => a.Photos.OrderBy(p => p.Order)) - .SingleOrDefaultAsync( - a => a.Id == request.AlbumId, - ct); - - if (album is null) - { - await SendNotFoundAsync(ct); - return; - } - - List photos = album.Photos - .Select(p => new AlbumPhotoDto( - p.Id, - p.OriginalUrl, - p.ThumbnailUrl, - p.Caption, - p.Order, - p.CreatedAt)) - .ToList(); - - await SendOkAsync( - new GetAlbumResponse( - album.Id, - album.Title, - photos, - album.CreatedAt), - ct); - } -} diff --git a/backend/Modules/Contents/Features/RemoveAlbum.cs b/backend/Modules/Contents/Features/RemoveAlbum.cs deleted file mode 100644 index dcef46c..0000000 --- a/backend/Modules/Contents/Features/RemoveAlbum.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Contents.Data; - -namespace Hutopy.Modules.Contents.Features; - -[PublicAPI] -public record RemoveAlbumRequest( - Guid AlbumId); - -[PublicAPI] -public sealed class RemoveAlbumRequestValidator : Validator -{ - public RemoveAlbumRequestValidator() - { - RuleFor(x => x.AlbumId) - .NotNull() - .NotEmpty(); - } -} - -[PublicAPI] -public class RemoveAlbumHandler( - ContentsDbContext context) - : Endpoint -{ - public override void Configure() - { - Delete("/api/albums/{AlbumId}"); - Options(o => o.WithTags("Albums")); - } - - public override async Task HandleAsync( - RemoveAlbumRequest request, - CancellationToken ct) - { - Guid userId = User.GetUserId(); - - Album? album = await context - .Albums - .Include(a => a.Photos) - .SingleOrDefaultAsync( - a => a.Id == request.AlbumId && a.CreatedBy == userId, - ct); - - if (album is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Soft delete the album - album.DeletedBy = userId; - album.DeletedAt = DateTimeOffset.UtcNow; - - // Soft delete all photos in the album - foreach (AlbumPhoto photo in album.Photos) - { - photo.DeletedBy = userId; - photo.DeletedAt = DateTimeOffset.UtcNow; - } - - await context.SaveChangesAsync(ct); - - await SendNoContentAsync(ct); - } -} diff --git a/backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs b/backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs deleted file mode 100644 index c0d3d57..0000000 --- a/backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Contents.Data; - -namespace Hutopy.Modules.Contents.Features; - -[PublicAPI] -public record RemovePhotoFromAlbumRequest( - Guid AlbumId, - Guid PhotoId); - -[PublicAPI] -public sealed class RemovePhotoFromAlbumRequestValidator : Validator -{ - public RemovePhotoFromAlbumRequestValidator() - { - RuleFor(x => x.AlbumId) - .NotNull() - .NotEmpty(); - - RuleFor(x => x.PhotoId) - .NotNull() - .NotEmpty(); - } -} - -[PublicAPI] -public class RemovePhotoFromAlbumHandler( - ContentsDbContext context) - : Endpoint -{ - public override void Configure() - { - Delete("/api/albums/{AlbumId}/photos/{PhotoId}"); - Options(o => o.WithTags("Albums")); - } - - public override async Task HandleAsync( - RemovePhotoFromAlbumRequest request, - CancellationToken ct) - { - Guid userId = User.GetUserId(); - - Album? album = await context - .Albums - .Include(a => a.Photos) - .SingleOrDefaultAsync( - a => a.Id == request.AlbumId && a.CreatedBy == userId, - ct); - - if (album is null) - { - await SendNotFoundAsync(ct); - return; - } - - AlbumPhoto? photo = album.Photos - .SingleOrDefault(p => p.Id == request.PhotoId); - - if (photo is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Soft delete the photo - photo.DeletedBy = userId; - photo.DeletedAt = DateTimeOffset.UtcNow; - - await context.SaveChangesAsync(ct); - - await SendNoContentAsync(ct); - } -} diff --git a/backend/Modules/Contents/Migrations/20250609212411_Initial.Designer.cs b/backend/Modules/Contents/Migrations/20250609212411_Initial.Designer.cs deleted file mode 100644 index 4f4f44c..0000000 --- a/backend/Modules/Contents/Migrations/20250609212411_Initial.Designer.cs +++ /dev/null @@ -1,134 +0,0 @@ -// -using System; -using Hutopy.Modules.Contents.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Contents.Migrations -{ - [DbContext(typeof(ContentsDbContext))] - [Migration("20250609212411_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Content") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("Title") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("Id"); - - b.ToTable("Albums", "Content"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AlbumId") - .HasColumnType("uuid"); - - b.Property("Caption") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("Order") - .HasColumnType("integer"); - - b.Property("OriginalUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("ThumbnailUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.HasKey("Id"); - - b.HasIndex("AlbumId"); - - b.ToTable("AlbumPhotos", "Content"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b => - { - b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album") - .WithMany("Photos") - .HasForeignKey("AlbumId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Album"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b => - { - b.Navigation("Photos"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Contents/Migrations/20250609212411_Initial.cs b/backend/Modules/Contents/Migrations/20250609212411_Initial.cs deleted file mode 100644 index 1b9c57c..0000000 --- a/backend/Modules/Contents/Migrations/20250609212411_Initial.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Contents.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Content"); - - migrationBuilder.CreateTable( - name: "Albums", - schema: "Content", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - IsDeleted = table.Column(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true), - Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Albums", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AlbumPhotos", - schema: "Content", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - IsDeleted = table.Column(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true), - AlbumId = table.Column(type: "uuid", nullable: false), - OriginalUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), - ThumbnailUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), - Caption = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Order = table.Column(type: "integer", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AlbumPhotos", x => x.Id); - table.ForeignKey( - name: "FK_AlbumPhotos_Albums_AlbumId", - column: x => x.AlbumId, - principalSchema: "Content", - principalTable: "Albums", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AlbumPhotos_AlbumId", - schema: "Content", - table: "AlbumPhotos", - column: "AlbumId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AlbumPhotos", - schema: "Content"); - - migrationBuilder.DropTable( - name: "Albums", - schema: "Content"); - } - } -} diff --git a/backend/Modules/Contents/Migrations/ContentsDbContextModelSnapshot.cs b/backend/Modules/Contents/Migrations/ContentsDbContextModelSnapshot.cs deleted file mode 100644 index 991cdae..0000000 --- a/backend/Modules/Contents/Migrations/ContentsDbContextModelSnapshot.cs +++ /dev/null @@ -1,131 +0,0 @@ -// -using System; -using Hutopy.Modules.Contents.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Contents.Migrations -{ - [DbContext(typeof(ContentsDbContext))] - partial class ContentsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Content") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("Title") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("Id"); - - b.ToTable("Albums", "Content"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AlbumId") - .HasColumnType("uuid"); - - b.Property("Caption") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("Order") - .HasColumnType("integer"); - - b.Property("OriginalUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("ThumbnailUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.HasKey("Id"); - - b.HasIndex("AlbumId"); - - b.ToTable("AlbumPhotos", "Content"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b => - { - b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album") - .WithMany("Photos") - .HasForeignKey("AlbumId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Album"); - }); - - modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b => - { - b.Navigation("Photos"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Contents/Models/ContentModel.cs b/backend/Modules/Contents/Models/ContentModel.cs deleted file mode 100644 index 1e90d96..0000000 --- a/backend/Modules/Contents/Models/ContentModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Hutopy.Modules.Contents.Models; - -[PublicAPI] -public class ContentModel -{ - public required Guid Id { get; init; } - public required Guid CreatedBy { get; init; } - public required string CreatedByName { get; init; } - public required string? CreatedByPortraitUrl { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public Guid? DeletedBy { get; init; } - public DateTimeOffset? DeletedAt { get; init; } - public required string Title { get; init; } - public required string Description { get; init; } - public string HtmlFileUrl { get; init; } = ""; - public required string[]? Urls { get; init; } - public string? ThumbnailUrl { get; init; } -} diff --git a/backend/Modules/Contents/Models/FollowModel.cs b/backend/Modules/Contents/Models/FollowModel.cs deleted file mode 100644 index 223941e..0000000 --- a/backend/Modules/Contents/Models/FollowModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Hutopy.Modules.Contents.Models; - -[PublicAPI] -public record FollowModel( - Guid CreatorId, - string CreatorName, - string? CreatorPortraitUrl); diff --git a/backend/Modules/Creators/Configuration/CreatorOptions.cs b/backend/Modules/Creators/Configuration/CreatorOptions.cs deleted file mode 100644 index 0711f44..0000000 --- a/backend/Modules/Creators/Configuration/CreatorOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Hutopy.Modules.Creators.Configuration; - -public class CreatorOptions -{ - public const string ConfigurationSection = "Creators"; - - public TimeSpan SlugReservationDuration { get; set; } -} diff --git a/backend/Modules/Creators/Contracts/CreatorReference.cs b/backend/Modules/Creators/Contracts/CreatorReference.cs deleted file mode 100644 index 1e3e5d2..0000000 --- a/backend/Modules/Creators/Contracts/CreatorReference.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Hutopy.Modules.Creators.Contracts; - -public record CreatorReference( - Guid Id, - string Name, - string? PortraitUrl, - bool OnboardingComplete, - bool AcceptCharges, - string? StripeAccountId); diff --git a/backend/Modules/Creators/Contracts/ICreatorLookup.cs b/backend/Modules/Creators/Contracts/ICreatorLookup.cs deleted file mode 100644 index 8f3c4cd..0000000 --- a/backend/Modules/Creators/Contracts/ICreatorLookup.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Modules.Creators.Contracts; - -public interface ICreatorLookup -{ - Task GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken = default); -} diff --git a/backend/Modules/Creators/Data/Creator.cs b/backend/Modules/Creators/Data/Creator.cs deleted file mode 100644 index 6534420..0000000 --- a/backend/Modules/Creators/Data/Creator.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Creators.Data; - -public class Creator -{ - public Guid Id { get; set; } - - public Guid CreatedBy { get; set; } - public DateTimeOffset CreatedAt { get; init; } - public Guid? DeletedBy { get; set; } - public DateTimeOffset? DeletedAt { get; set; } - - /// - /// Soft‑delete flag (false by default, true once DeletedAt is set) - /// - public bool IsDeleted { get; private set; } // private set → EF updates it - - [MaxLength(2048)] public string? BannerUrl { get; set; } - [MaxLength(2048)] public string? PortraitUrl { get; set; } - public bool Verified { get; set; } - [MaxLength(256)] public required string Name { get; set; } - [MaxLength(128)] public required string Slug { get; set; } - [MaxLength(256)] public string? Title { get; set; } - - [MaxLength(21)] public string? StripeAccountId { get; set; } - public bool IsStripeDetailsSubmitted { get; set; } - public bool IsStripePayoutReady { get; set; } - public bool IsStripeChargesEnabled { get; set; } - public Socials Socials { get; set; } = new(); - public Presentation Presentation { get; set; } = new() { Description = "Welcome to my profile!" }; -} diff --git a/backend/Modules/Creators/Data/CreatorsDbContext.cs b/backend/Modules/Creators/Data/CreatorsDbContext.cs deleted file mode 100644 index bf0744e..0000000 --- a/backend/Modules/Creators/Data/CreatorsDbContext.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Hutopy.Modules.Creators.Data; - -public class CreatorsDbContext( - DbContextOptions options) - : DbContext(options) -{ - public const string SchemaName = "Creators"; - - public DbSet Creators => Set(); - public DbSet Slugs => Set(); - - protected override void OnModelCreating( - ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder - .Entity() - .Property(x => x.NormalizedName) - .HasComputedColumnSql("LOWER(\"Name\")", true); - - modelBuilder - .Entity() - .HasIndex(x => x.NormalizedName) - .IsUnique(); - - modelBuilder - .Entity() - .Property(c => c.IsDeleted) - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); // bool - - modelBuilder - .Entity() - .OwnsOne(x => x.Socials) - .ToTable(nameof(Socials)); - - modelBuilder - .Entity() - .OwnsOne(x => x.Presentation) - .ToTable(nameof(Presentation)); - - modelBuilder - .Entity() - .HasQueryFilter(c => !c.IsDeleted); - } -} diff --git a/backend/Modules/Creators/Data/Presentation.cs b/backend/Modules/Creators/Data/Presentation.cs deleted file mode 100644 index 703f618..0000000 --- a/backend/Modules/Creators/Data/Presentation.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Creators.Data; - -public class Presentation -{ - public string Description { get; set; } = null!; - [MaxLength(2048)] public string? VideoUrl { get; set; } - [MaxLength(256)] public string? PhoneNumber { get; set; } - [MaxLength(256)] public string? Email { get; set; } -} diff --git a/backend/Modules/Creators/Data/Slugs.cs b/backend/Modules/Creators/Data/Slugs.cs deleted file mode 100644 index f42c5d2..0000000 --- a/backend/Modules/Creators/Data/Slugs.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Creators.Data; - -public class Slugs -{ - public Guid Id { get; set; } - public Guid CreatedBy { get; set; } - public DateTimeOffset CreatedAt { get; init; } - public Guid? UsedBy { get; set; } - [MaxLength(128)] public string Name { get; set; } = null!; - [MaxLength(128)] public string NormalizedName { get; set; } = null!; - public DateTimeOffset ReservedUntil { get; set; } -} diff --git a/backend/Modules/Creators/Data/Socials.cs b/backend/Modules/Creators/Data/Socials.cs deleted file mode 100644 index d85b5da..0000000 --- a/backend/Modules/Creators/Data/Socials.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Creators.Data; - -public class Socials -{ - [MaxLength(2048)] public string? FacebookUrl { get; set; } - [MaxLength(2048)] public string? InstagramUrl { get; set; } - [MaxLength(2048)] public string? XUrl { get; set; } - [MaxLength(2048)] public string? LinkedInUrl { get; set; } - [MaxLength(2048)] public string? TikTokUrl { get; set; } - [MaxLength(2048)] public string? YoutubeUrl { get; set; } - [MaxLength(2048)] public string? RedditUrl { get; set; } - [MaxLength(2048)] public string? WebsiteUrl { get; set; } -} diff --git a/backend/Modules/Creators/DependencyInjection.cs b/backend/Modules/Creators/DependencyInjection.cs deleted file mode 100644 index 72ea53f..0000000 --- a/backend/Modules/Creators/DependencyInjection.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Hutopy.Modules.Creators.Configuration; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Creators.Data; -using Hutopy.Modules.Creators.Services; - -namespace Hutopy.Modules.Creators; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddCreatorModule( - this WebApplicationBuilder builder, - Action? configureAction = null) - { - builder.Services.Configure( - builder.Configuration.GetSection(CreatorOptions.ConfigurationSection)); - builder.Services.AddScoped(); - - builder.Services.AddDbContext(configureAction); - builder.Services.AddTransient(); - - return builder; - } - - public static async Task UseCreatorModuleAsync( - this IApplicationBuilder app, - CancellationToken cancellationToken = default) - { - IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); - using IServiceScope scope = scopeFactory.CreateScope(); - await using CreatorsDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - - return app; - } -} diff --git a/backend/Modules/Creators/Features/ChangeBanner.cs b/backend/Modules/Creators/Features/ChangeBanner.cs deleted file mode 100644 index ef0f62a..0000000 --- a/backend/Modules/Creators/Features/ChangeBanner.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public static class ChangeBanner -{ - public record Request( - Guid CreatorId, - IFormFile File); - - public record Response( - string BlobUrl); - - public class Handler( - CreatorsDbContext context, - IBlobStorage blobStorage) - : Endpoint - { - public override void Configure() - { - Post("/api/creators/{CreatorId}/banner"); - Options(o => o.WithTags("Creators")); - AllowFileUploads(); - } - - public override async Task HandleAsync( - Request request, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .SingleOrDefaultAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - string blobUrl = await blobStorage.UploadFileAsync( - ContainerNames.Creators, - $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}", - request.File.OpenReadStream(), - request.File.ContentType, - ct); - - creator.BannerUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; - - await context.SaveChangesAsync(ct); - - await SendOkAsync( - new Response(blobUrl), - ct); - } - } -} diff --git a/backend/Modules/Creators/Features/ChangeEmail.cs b/backend/Modules/Creators/Features/ChangeEmail.cs deleted file mode 100644 index 00cb221..0000000 --- a/backend/Modules/Creators/Features/ChangeEmail.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeEmailRequest( - Guid CreatorId, - string? Email); - -[PublicAPI] -public sealed class ChangeEmailRequestValidator : Validator -{ - public ChangeEmailRequestValidator() - { - RuleFor(x => x.CreatorId) - .NotEmpty() - .WithMessage("Creator ID is required"); - - RuleFor(x => x.Email) - .Must(email => email == null || !string.IsNullOrWhiteSpace(email)) - .WithMessage("Email cannot be empty if provided"); - } -} - -[PublicAPI] -public class ChangeEmailHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/email"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangeEmailRequest request, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .Include(c => c.Presentation) - .SingleOrDefaultAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Check if the current user is the creator - if (creator.CreatedBy != User.GetUserId()) - { - await SendUnauthorizedAsync(ct); - return; - } - - creator.Presentation.Email = request.Email?.Trim(); - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangeLogo.cs b/backend/Modules/Creators/Features/ChangeLogo.cs deleted file mode 100644 index 84a9d48..0000000 --- a/backend/Modules/Creators/Features/ChangeLogo.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeLogoRequest( - Guid CreatorId, - IFormFile File); - -[PublicAPI] -public record ChangeLogoResponse( - string BlobUrl); - -[PublicAPI] -public sealed class ChangeLogoRequestValidator : Validator -{ - public ChangeLogoRequestValidator() - { - RuleFor(x => x.CreatorId) - .NotNull() - .NotEmpty(); - - RuleFor(x => x.File) - .NotNull() - .NotEmpty(); - } -} - -[PublicAPI] -public class ChangeLogoHandler( - CreatorsDbContext context, - IBlobStorage blobStorage) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/logo"); - Options(o => o.WithTags("Creators")); - AllowFileUploads(); - } - - public override async Task HandleAsync( - ChangeLogoRequest request, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .SingleOrDefaultAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - string blobUrl = await blobStorage.UploadFileAsync( - ContainerNames.Creators, - $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", - request.File.OpenReadStream(), - request.File.ContentType, - ct); - - creator.PortraitUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; - - await context.SaveChangesAsync(ct); - - await SendOkAsync( - new ChangeLogoResponse(blobUrl), - ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangeName.cs b/backend/Modules/Creators/Features/ChangeName.cs deleted file mode 100644 index 5d0bd49..0000000 --- a/backend/Modules/Creators/Features/ChangeName.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeNameRequest( - Guid CreatorId, - string Name); - -[PublicAPI] -internal sealed class ChangeNameRequestValidator - : Validator -{ - public ChangeNameRequestValidator() - { - RuleFor(r => r.Name) - .NotNull().WithMessage("You should specify the Name") - .NotEmpty().WithMessage("You should specify a valid/not empty Name"); - } -} - -[PublicAPI] -public class ChangeNameHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/name"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangeNameRequest request, - CancellationToken ct) - { - Creator creator = await context - .Creators - .SingleAsync( - c => c.Id == request.CreatorId, - ct); - - creator.Name = request.Name; - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangePhoneNumber.cs b/backend/Modules/Creators/Features/ChangePhoneNumber.cs deleted file mode 100644 index 54d22d7..0000000 --- a/backend/Modules/Creators/Features/ChangePhoneNumber.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangePhoneNumberRequest( - Guid CreatorId, - string? PhoneNumber); - -[PublicAPI] -public sealed class ChangePhoneNumberRequestValidator : Validator -{ - public ChangePhoneNumberRequestValidator() - { - RuleFor(x => x.CreatorId) - .NotEmpty() - .WithMessage("Creator ID is required"); - - RuleFor(x => x.PhoneNumber) - .Must(phone => phone == null || !string.IsNullOrWhiteSpace(phone)) - .WithMessage("Phone number cannot be empty if provided"); - } -} - -[PublicAPI] -public class ChangePhoneNumberHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/phone"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangePhoneNumberRequest request, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .Include(c => c.Presentation) - .SingleOrDefaultAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Check if the current user is the creator - if (creator.CreatedBy != User.GetUserId()) - { - await SendUnauthorizedAsync(ct); - return; - } - - creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim(); - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangePresentationInfos.cs b/backend/Modules/Creators/Features/ChangePresentationInfos.cs deleted file mode 100644 index a9955b4..0000000 --- a/backend/Modules/Creators/Features/ChangePresentationInfos.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Hutopy.Infrastructure.YouTube; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangePresentationInfosRequest( - Guid CreatorId, - string Description, - string? VideoUrl); - -[PublicAPI] -public sealed class ChangePresentationInfosRequestValidator : Validator -{ - public ChangePresentationInfosRequestValidator() - { - RuleFor(x => x.CreatorId) - .NotEmpty() - .WithMessage("Creator ID is required"); - - RuleFor(x => x.Description) - .NotEmpty() - .WithMessage("Description is required") - .MaximumLength(2000) - .WithMessage("Description cannot exceed 2000 characters"); - - RuleFor(x => x.VideoUrl) - .Must(url => url == null || YouTubeUrlHelper.IsValidYouTubeUrlOrId(url)) - .WithMessage("Invalid YouTube URL or video ID format"); - } -} - -[PublicAPI] -public class ChangePresentationInfosHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/presentation-infos"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangePresentationInfosRequest request, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .Include(c => c.Presentation) - .SingleOrDefaultAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Update the presentation info with the new values - creator.Presentation.Description = request.Description.Trim(); - creator.Presentation.VideoUrl = request.VideoUrl != null - ? YouTubeUrlHelper.ExtractVideoId(request.VideoUrl.Trim()) - : null; - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangeSlug.cs b/backend/Modules/Creators/Features/ChangeSlug.cs deleted file mode 100644 index c209fcc..0000000 --- a/backend/Modules/Creators/Features/ChangeSlug.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; -using Microsoft.EntityFrameworkCore.Storage; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeSlugRequest( - Guid CreatorId, - Guid SlugReservationId); - -[PublicAPI] -internal sealed class ChangeSlugRequestValidator - : Validator -{ - public ChangeSlugRequestValidator() - { - RuleFor(r => r.CreatorId) - .NotNull().WithMessage("You should specify the CreatorId") - .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); - - RuleFor(r => r.SlugReservationId) - .NotNull().WithMessage("You should specify the SlugReservationId") - .NotEmpty().WithMessage("You should specify a valid/not empty SlugReservationId"); - } -} - -[PublicAPI] -public class ChangeSlugHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Put("/api/creators/{CreatorId}/slug"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangeSlugRequest request, - CancellationToken ct) - { - await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct); - - try - { - Creator creator = await context - .Creators - .SingleAsync( - c => c.Id == request.CreatorId, - ct); - - if (creator.CreatedBy != User.GetUserId()) - { - await SendUnauthorizedAsync(ct); - return; - } - - Slugs? reservation = await context - .Slugs - .FirstOrDefaultAsync( - s => s.Id == request.SlugReservationId, - ct); - - if (reservation is null) - { - await SendNotFoundAsync(ct); - return; - } - - Slugs? previousReservation = await context - .Slugs - .FirstOrDefaultAsync( - s => s.UsedBy == request.CreatorId, - ct); - - if (previousReservation is null) - { - await SendErrorsAsync(cancellation: ct); - return; - } - - context.Remove(previousReservation); - reservation.UsedBy = creator.Id; - creator.Slug = reservation.NormalizedName; - - await context.SaveChangesAsync(ct); - - await transaction.CommitAsync(ct); - - await SendOkAsync(ct); - } - catch - { - await transaction.RollbackAsync(ct); - } - } -} diff --git a/backend/Modules/Creators/Features/ChangeSocials.cs b/backend/Modules/Creators/Features/ChangeSocials.cs deleted file mode 100644 index 6dbd462..0000000 --- a/backend/Modules/Creators/Features/ChangeSocials.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeSocialsRequest( - Guid CreatorId, - string? FacebookUrl, - string? InstagramUrl, - string? XUrl, - string? LinkedInUrl, - string? TikTokUrl, - string? YoutubeUrl, - string? RedditUrl, - string? WebsiteUrl); - -[PublicAPI] -public class ChangeSocialsHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/socials"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct) - { - Creator creator = await context - .Creators - .Include(c => c.Socials) - .SingleAsync( - c => c.Id == request.CreatorId, - ct); - - creator.Socials.FacebookUrl = request.FacebookUrl; - creator.Socials.InstagramUrl = request.InstagramUrl; - creator.Socials.XUrl = request.XUrl; - creator.Socials.LinkedInUrl = request.LinkedInUrl; - creator.Socials.TikTokUrl = request.TikTokUrl; - creator.Socials.YoutubeUrl = request.YoutubeUrl; - creator.Socials.RedditUrl = request.RedditUrl; - creator.Socials.WebsiteUrl = request.WebsiteUrl; - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ChangeTitle.cs b/backend/Modules/Creators/Features/ChangeTitle.cs deleted file mode 100644 index 49d42a5..0000000 --- a/backend/Modules/Creators/Features/ChangeTitle.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ChangeTitleRequest( - Guid CreatorId, - string? Title); - -[PublicAPI] -public class ChangeTitleHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/{CreatorId}/title"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ChangeTitleRequest request, - CancellationToken ct) - { - Creator creator = await context - .Creators - .SingleAsync( - c => c.Id == request.CreatorId, - ct); - - creator.Title = request.Title; - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/CheckStatusStripe.cs b/backend/Modules/Creators/Features/CheckStatusStripe.cs deleted file mode 100644 index 3195572..0000000 --- a/backend/Modules/Creators/Features/CheckStatusStripe.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; -using Microsoft.Extensions.Options; -using Stripe; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record CheckStatusStripeResponse( - bool IsStripeAccountPresent, - bool IsStripeOnboardingComplete, - bool IsStripeChargesEnabled, - bool IsStripePayoutReady -); - -public class CheckStatusStripeIdHandler( - IOptionsSnapshot stripeOptions, - CreatorsDbContext dbContext) - : EndpointWithoutRequest -{ - public override void Configure() - { - Post("/api/stripe/check-status"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - CancellationToken ct) - { - // 1. Get the creator's information - Guid creatorId = HttpContext.User.GetUserId(); - - // 2. Get or create the creator - Creator? creator = await dbContext.Creators.SingleOrDefaultAsync(c => c.Id == creatorId, ct); - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - // 3. The Creator is not being onboarded - if (string.IsNullOrWhiteSpace(creator.StripeAccountId)) - { - await SendErrorsAsync(cancellation: ct); - return; - } - - // 4. Update Creator's stripe account information - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - AccountService accountService = new(); - Account? account = await accountService.GetAsync(creator.StripeAccountId, cancellationToken: ct); - creator.IsStripePayoutReady = account.PayoutsEnabled; - creator.IsStripeChargesEnabled = account.ChargesEnabled; - creator.IsStripeDetailsSubmitted = account.DetailsSubmitted; - await dbContext.SaveChangesAsync(ct); - - // 6. Return the account link URL to the client - await SendOkAsync( - new CheckStatusStripeResponse( - creator.StripeAccountId != null, - creator.IsStripeDetailsSubmitted, - creator.IsStripeChargesEnabled, - creator.IsStripePayoutReady - ), - ct); - } -} diff --git a/backend/Modules/Creators/Features/ConnectStripe.cs b/backend/Modules/Creators/Features/ConnectStripe.cs deleted file mode 100644 index fc0bf8d..0000000 --- a/backend/Modules/Creators/Features/ConnectStripe.cs +++ /dev/null @@ -1,91 +0,0 @@ -using Hutopy.Infrastructure.Configuration; -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; -using Microsoft.Extensions.Options; -using Stripe; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ConnectStripeResponse( - string Url); - -public class ConnectStripeIdHandler( - IOptionsSnapshot websiteOptions, - IOptionsSnapshot stripeOptions, - CreatorsDbContext dbContext) - : EndpointWithoutRequest -{ - public override void Configure() - { - Post("/api/stripe/connect"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - CancellationToken ct) - { - // 1. Get the creator's information - Guid creatorId = HttpContext.User.GetUserId(); - string email = HttpContext.User.GetEmail(); - - // 2. Get or create the creator - Creator? creator = await dbContext - .Creators - .SingleOrDefaultAsync( - c => c.Id == creatorId, - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - // 3. Create a Stripe account - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - AccountService accountService = new(); - if (string.IsNullOrWhiteSpace(creator.StripeAccountId)) - { - Account? account = await accountService.CreateAsync( - new AccountCreateOptions - { - Type = "express", - Capabilities = new AccountCapabilitiesOptions - { - CardPayments = new AccountCapabilitiesCardPaymentsOptions { Requested = true }, - Transfers = new AccountCapabilitiesTransfersOptions { Requested = true } - }, - Email = email - }, - cancellationToken: ct); - - // 5. Update the creator's Stripe account ID - creator.StripeAccountId = account.Id; - await dbContext.SaveChangesAsync(ct); - } - - // 4. Check if the creator already has a Stripe account - if (creator is { IsStripeDetailsSubmitted: true, IsStripeChargesEnabled: true, IsStripePayoutReady: true }) - { - await SendErrorsAsync(cancellation: ct); - return; - } - - // 5. Create an account link - AccountLinkService accountLinkService = new(); - AccountLink? accountLink = await accountLinkService.CreateAsync( - new AccountLinkCreateOptions - { - Account = creator.StripeAccountId, - RefreshUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=retry", - ReturnUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=complete", - Type = "account_onboarding" - }, - cancellationToken: ct); - - // 6. Return the account link URL to the client - await SendOkAsync(new ConnectStripeResponse(accountLink.Url), ct); - } -} diff --git a/backend/Modules/Creators/Features/CreateCreator.cs b/backend/Modules/Creators/Features/CreateCreator.cs deleted file mode 100644 index 35d764e..0000000 --- a/backend/Modules/Creators/Features/CreateCreator.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; -using Microsoft.EntityFrameworkCore.Storage; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record CreateCreatorRequest( - Guid SlugReservationId, - Guid CreatorId); - -[PublicAPI] -public sealed class CreateCreatorRequestValidator : Validator -{ - public CreateCreatorRequestValidator() - { - RuleFor(r => r.SlugReservationId) - .NotNull() - .NotEmpty() - .WithMessage("You should specify a valid SlugReservationId"); - - RuleFor(r => r.CreatorId) - .NotNull() - .NotEmpty() - .WithMessage("You should specify a valid CreatorId"); - } -} - -[PublicAPI] -public sealed class CreateCreatorHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - CreateCreatorRequest req, - CancellationToken ct) - { - await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct); - - try - { - Slugs slug = await context - .Slugs - .SingleAsync(s => s.Id == req.SlugReservationId, ct); - - if (slug.UsedBy is not null - || slug.ReservedUntil < DateTimeOffset.UtcNow - || slug.CreatedBy != User.GetUserId()) - { - await SendErrorsAsync(500, ct); - return; - } - - slug.UsedBy = req.CreatorId; - - await context.Creators.AddAsync( - new Creator - { - Id = req.CreatorId, CreatedBy = User.GetUserId(), Name = slug.Name, Slug = slug.NormalizedName - }, - ct); - - await context.SaveChangesAsync(ct); - - await transaction.CommitAsync(ct); - - await SendOkAsync(ct); - } - catch (Exception) - { - await transaction.RollbackAsync(ct); - } - } -} diff --git a/backend/Modules/Creators/Features/GetCreatorById.cs b/backend/Modules/Creators/Features/GetCreatorById.cs deleted file mode 100644 index 7659831..0000000 --- a/backend/Modules/Creators/Features/GetCreatorById.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public sealed class GetCreatorByIdRequest -{ - public required Guid CreatorId { get; set; } -} - -[UsedImplicitly] -public sealed class GetCreatorByIdRequestValidator - : Validator -{ - public GetCreatorByIdRequestValidator() - { - RuleFor(r => r.CreatorId) - .NotNull().WithMessage("You should specify the CreatorId") - .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); - } -} - -[PublicAPI] -public class GetCreatorByIdHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/creators/{CreatorId}"); - Options(o => o.WithTags("Creators")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetCreatorByIdRequest req, - CancellationToken ct) - { - Creator? creator = await context - .Creators - .FindAsync( - [req.CreatorId], - ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - } - else - { - await SendAsync(creator, cancellation: ct); - } - } -} diff --git a/backend/Modules/Creators/Features/GetCreatorBySlug.cs b/backend/Modules/Creators/Features/GetCreatorBySlug.cs deleted file mode 100644 index d8490bd..0000000 --- a/backend/Modules/Creators/Features/GetCreatorBySlug.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public sealed class GetCreatorBySlugRequest -{ - public required string Name { get; set; } -} - -[PublicAPI] -public record GetCreatorBySlugResponse -{ - public Guid Id { get; init; } - public Guid CreatedBy { get; init; } - public DateTimeOffset CreatedAt { get; init; } - public Guid? DeletedBy { get; init; } - public DateTimeOffset? DeletedAt { get; init; } - public bool IsDeleted { get; init; } - public bool Verified { get; init; } - public bool AcceptDonation { get; init; } - public string? BannerUrl { get; init; } - public string? PortraitUrl { get; init; } - public required string Slug { get; init; } - public required string Name { get; init; } - public string? Title { get; init; } - public Socials? Socials { get; init; } - public Presentation? Presentation { get; init; } -} - -[UsedImplicitly] -public sealed class GetCreatorBySlugRequestValidator - : Validator -{ - public GetCreatorBySlugRequestValidator() - { - RuleFor(r => r.Name) - .NotNull().WithMessage("You should specify the Name") - .NotEmpty().WithMessage("You should specify a valid/not empty Name"); - } -} - -[PublicAPI] -public class GetCreatorBySlugHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/creators/@{Name}"); - Options(o => o.WithTags("Creators")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetCreatorBySlugRequest req, - CancellationToken ct) - { - string creatorName = req.Name.ToLower(); - - GetCreatorBySlugResponse? response = await context - .Creators - .IgnoreQueryFilters() - .Where(c => EF.Functions.ILike(c.Slug, creatorName)) - .AsNoTracking() - .Select(c => new GetCreatorBySlugResponse - { - Id = c.Id, - CreatedBy = c.CreatedBy, - CreatedAt = c.CreatedAt, - DeletedBy = c.DeletedBy, - DeletedAt = c.DeletedAt, - IsDeleted = c.IsDeleted, - Verified = c.Verified, - BannerUrl = c.BannerUrl, - PortraitUrl = c.PortraitUrl, - Slug = c.Slug, - Name = c.Name, - Title = c.Title, - AcceptDonation = c.IsStripeChargesEnabled && c.IsStripePayoutReady, - Socials = c.Socials, - Presentation = c.Presentation - }) - .SingleOrDefaultAsync(ct); - - if (response is null) - { - await SendNotFoundAsync(ct); - return; - } - - bool isOwner = User.Identity?.IsAuthenticated == true - && User.GetUserId() == response.CreatedBy; - - if (response.IsDeleted && !isOwner) - { - await SendNotFoundAsync(ct); - } - else - { - await SendAsync(response, cancellation: ct); - } - } -} diff --git a/backend/Modules/Creators/Features/GetCreatorProfile.cs b/backend/Modules/Creators/Features/GetCreatorProfile.cs deleted file mode 100644 index 36ba562..0000000 --- a/backend/Modules/Creators/Features/GetCreatorProfile.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public sealed class GetCreatorProfileResponse -{ - public Guid Id { get; set; } - public Guid CreatedBy { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public Guid? DeletedBy { get; set; } - public DateTimeOffset? DeletedAt { get; set; } - public bool IsDeleted { get; set; } - public required string Name { get; set; } - public required string Slug { get; set; } - public string? Title { get; set; } - public bool Verified { get; set; } - public bool IsStripeAccountPresent { get; set; } - public bool IsStripeDetailsSubmitted { get; set; } - public bool IsStripePayoutReady { get; set; } - public bool IsStripeChargesEnabled { get; set; } - public required Presentation Presentation { get; set; } - public required Socials Socials { get; set; } -} - -[PublicAPI] -public class GetCreatorProfileHandler( - CreatorsDbContext context) - : EndpointWithoutRequest -{ - public override void Configure() - { - Get("/api/creators/profile"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - CancellationToken ct) - { - GetCreatorProfileResponse? creator = await context - .Creators - .IgnoreQueryFilters() - .Where(c => c.Id == HttpContext.User.GetUserId()) - .AsNoTracking() - .Select(c => new GetCreatorProfileResponse - { - Id = c.Id, - CreatedBy = c.CreatedBy, - CreatedAt = c.CreatedAt, - DeletedBy = c.DeletedBy, - DeletedAt = c.DeletedAt, - IsDeleted = c.IsDeleted, - Name = c.Name, - Slug = c.Slug, - Title = c.Title, - Verified = c.Verified, - IsStripeAccountPresent = !string.IsNullOrWhiteSpace(c.StripeAccountId), - IsStripeDetailsSubmitted = c.IsStripeDetailsSubmitted, - IsStripeChargesEnabled = c.IsStripeChargesEnabled, - IsStripePayoutReady = c.IsStripePayoutReady, - Presentation = c.Presentation, - Socials = c.Socials - }) - .SingleOrDefaultAsync(ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - } - else - { - await SendAsync(creator, cancellation: ct); - } - } -} diff --git a/backend/Modules/Creators/Features/RemoveCreator.cs b/backend/Modules/Creators/Features/RemoveCreator.cs deleted file mode 100644 index 884ecd9..0000000 --- a/backend/Modules/Creators/Features/RemoveCreator.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record RemoveCreatorRequest( - string CreatorSlug); - -[UsedImplicitly] -public sealed class RemoveCreatorRequestValidator : Validator -{ - public RemoveCreatorRequestValidator() - { - RuleFor(r => r.CreatorSlug) - .NotNull() - .NotEmpty() - .WithMessage("You should specify a valid CreatorSlug"); - } -} - -[PublicAPI] -public sealed class RemoveCreatorHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Delete("/api/creators/@{CreatorSlug}"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - RemoveCreatorRequest req, - CancellationToken ct) - { - string creatorSlug = req.CreatorSlug.ToLower(); - - Creator? creator = await context - .Creators - .Where(c => EF.Functions.ILike(c.Slug, creatorSlug)) - .SingleOrDefaultAsync(ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - if (creator.CreatedBy != User.GetUserId()) - { - await SendUnauthorizedAsync(ct); - return; - } - - creator.DeletedAt = DateTimeOffset.UtcNow; - creator.DeletedBy = User.GetUserId(); - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/ReserveSlug.cs b/backend/Modules/Creators/Features/ReserveSlug.cs deleted file mode 100644 index 68b3189..0000000 --- a/backend/Modules/Creators/Features/ReserveSlug.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Net; -using FluentValidation.Results; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Configuration; -using Hutopy.Modules.Creators.Data; -using Hutopy.Modules.Creators.Services; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.Options; -using Npgsql; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record ReserveSlugRequest -{ - public required Guid ReservationId { get; set; } - public string Slug { get; set; } = null!; -} - -[PublicAPI] -public sealed class ReserveSlugRequestValidator : Validator -{ - public ReserveSlugRequestValidator() - { - RuleFor(r => r.Slug) - .NotEmpty() - .NotNull() - .WithMessage("You should specify a valid Slug"); - } -} - -[PublicAPI] -public sealed class ReserveSlug( - CreatorsDbContext context, - IOptions opts, - SlugPurger slugPurger) - : Endpoint -{ - public override void Configure() - { - Post("/api/creators/@{Slug}/reserve"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - ReserveSlugRequest req, - CancellationToken ct) - { - await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct); - - try - { - // First, purge any expired slugs - await slugPurger.PurgeExpiredSlugsAsync(ct); - - Slugs? reservation = await context.Slugs.FirstOrDefaultAsync( - s => s.Id == req.ReservationId && s.CreatedBy == User.GetUserId(), - ct); - - if (reservation == null) - { - reservation = new Slugs - { - Id = req.ReservationId, CreatedBy = User.GetUserId(), CreatedAt = DateTimeOffset.UtcNow - }; - - context.Slugs.Attach(reservation); - context.Entry(reservation).State = EntityState.Added; - } - - reservation.Name = req.Slug; - reservation.ReservedUntil = DateTimeOffset.UtcNow + opts.Value.SlugReservationDuration; - - await context.SaveChangesAsync(ct); - - await transaction.CommitAsync(ct); - - await SendOkAsync(new { Message = "Slug reserved." }, ct); - } - catch (Exception e) - { - await transaction.RollbackAsync(ct); - - Logger.LogError("Transaction failed: {Message}", e.Message); - - if (e.InnerException is PostgresException innerException) - { - if (innerException.ConstraintName == "IX_Slugs_NormalizedName") - { - await SendResultAsync(new ProblemDetails( - [ - new ValidationFailure(nameof(Slugs.Name), - "The name is already taken.") - ], - (int)HttpStatusCode.Conflict)); - } - } - else - { - await SendResultAsync(new ProblemDetails( - [ - new ValidationFailure(nameof(Slugs.Name), - e.Message) - ], - (int)HttpStatusCode.Conflict)); - } - } - } -} diff --git a/backend/Modules/Creators/Features/RestoreCreator.cs b/backend/Modules/Creators/Features/RestoreCreator.cs deleted file mode 100644 index bbf4b0e..0000000 --- a/backend/Modules/Creators/Features/RestoreCreator.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public record RestoreCreatorRequest( - string CreatorSlug); - -[UsedImplicitly] -public sealed class RestoreCreatorRequestValidator : Validator -{ - public RestoreCreatorRequestValidator() - { - RuleFor(r => r.CreatorSlug) - .NotNull() - .NotEmpty() - .WithMessage("You should specify a valid CreatorSlug"); - } -} - -[PublicAPI] -public sealed class RestoreCreatorHandler( - CreatorsDbContext context) - : Endpoint -{ - public override void Configure() - { - Put("/api/creators/@{CreatorSlug}/restore"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync( - RestoreCreatorRequest req, - CancellationToken ct) - { - string creatorSlug = req.CreatorSlug.ToLower(); - - Creator? creator = await context - .Creators - .IgnoreQueryFilters() - .Where(c => EF.Functions.ILike(c.Slug, creatorSlug)) - .SingleOrDefaultAsync(ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - if (creator.CreatedBy != User.GetUserId()) - { - await SendUnauthorizedAsync(ct); - return; - } - - creator.DeletedAt = null; - creator.DeletedBy = null; - - await context.SaveChangesAsync(ct); - - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Features/RevokeStripe.cs b/backend/Modules/Creators/Features/RevokeStripe.cs deleted file mode 100644 index cc2269f..0000000 --- a/backend/Modules/Creators/Features/RevokeStripe.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Features; - -[PublicAPI] -public class RemoveStripeHandler( - CreatorsDbContext dbContext) - : EndpointWithoutRequest -{ - public override void Configure() - { - Delete("/api/stripe"); - Options(o => o.WithTags("Creators")); - } - - public override async Task HandleAsync(CancellationToken ct) - { - // 1. Get the creator's ID from the authenticated user - Guid creatorId = HttpContext.User.GetUserId(); - - // 2. Retrieve the creator from the database - Creator? creator = await dbContext - .Creators - .SingleOrDefaultAsync( - c => c.Id == creatorId, - ct); - - // 3. If the creator doesn't exist or has no Stripe account linked, return 404 - if (creator is null || string.IsNullOrWhiteSpace(creator.StripeAccountId)) - { - await SendNotFoundAsync(ct); - return; - } - - // 4. Remove Stripe configuration - creator.StripeAccountId = null; - creator.IsStripeDetailsSubmitted = false; - creator.IsStripeChargesEnabled = false; - creator.IsStripePayoutReady = false; - - // 5. Persist changes - await dbContext.SaveChangesAsync(ct); - - // 6. Respond with success - await SendOkAsync(ct); - } -} diff --git a/backend/Modules/Creators/Migrations/20250609203815_Initial.Designer.cs b/backend/Modules/Creators/Migrations/20250609203815_Initial.Designer.cs deleted file mode 100644 index cedee10..0000000 --- a/backend/Modules/Creators/Migrations/20250609203815_Initial.Designer.cs +++ /dev/null @@ -1,221 +0,0 @@ -// -using System; -using Hutopy.Modules.Creators.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Creators.Migrations -{ - [DbContext(typeof(CreatorsDbContext))] - [Migration("20250609203815_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Creators") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AcceptDonation") - .HasColumnType("boolean"); - - b.Property("BannerUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("IsStripeChargesEnabled") - .HasColumnType("boolean"); - - b.Property("IsStripeOnboardingComplete") - .HasColumnType("boolean"); - - b.Property("IsStripePayoutReady") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PortraitUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("StripeAccountId") - .HasMaxLength(21) - .HasColumnType("character varying(21)"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Verified") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.ToTable("Creators", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("NormalizedName") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasComputedColumnSql("LOWER(\"Name\")", true); - - b.Property("ReservedUntil") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Slugs", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b1.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("VideoUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Presentation", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("FacebookUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("InstagramUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("LinkedInUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("RedditUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("TikTokUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("WebsiteUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("XUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("YoutubeUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Socials", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.Navigation("Presentation") - .IsRequired(); - - b.Navigation("Socials") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Creators/Migrations/20250609203815_Initial.cs b/backend/Modules/Creators/Migrations/20250609203815_Initial.cs deleted file mode 100644 index 65e79ab..0000000 --- a/backend/Modules/Creators/Migrations/20250609203815_Initial.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Creators.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Creators"); - - migrationBuilder.CreateTable( - name: "Creators", - schema: "Creators", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), - IsDeleted = table.Column(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true), - BannerUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - PortraitUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - Verified = table.Column(type: "boolean", nullable: false), - Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), - Slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - StripeAccountId = table.Column(type: "character varying(21)", maxLength: 21, nullable: true), - IsStripeOnboardingComplete = table.Column(type: "boolean", nullable: false), - IsStripePayoutReady = table.Column(type: "boolean", nullable: false), - IsStripeChargesEnabled = table.Column(type: "boolean", nullable: false), - AcceptDonation = table.Column(type: "boolean", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Creators", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Slugs", - schema: "Creators", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UsedBy = table.Column(type: "uuid", nullable: true), - Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - NormalizedName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, computedColumnSql: "LOWER(\"Name\")", stored: true), - ReservedUntil = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Slugs", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Presentation", - schema: "Creators", - columns: table => new - { - CreatorId = table.Column(type: "uuid", nullable: false), - Description = table.Column(type: "text", nullable: false), - VideoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - PhoneNumber = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Presentation", x => x.CreatorId); - table.ForeignKey( - name: "FK_Presentation_Creators_CreatorId", - column: x => x.CreatorId, - principalSchema: "Creators", - principalTable: "Creators", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "Socials", - schema: "Creators", - columns: table => new - { - CreatorId = table.Column(type: "uuid", nullable: false), - FacebookUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - InstagramUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - XUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - LinkedInUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - TikTokUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - YoutubeUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - RedditUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - WebsiteUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Socials", x => x.CreatorId); - table.ForeignKey( - name: "FK_Socials_Creators_CreatorId", - column: x => x.CreatorId, - principalSchema: "Creators", - principalTable: "Creators", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Slugs_NormalizedName", - schema: "Creators", - table: "Slugs", - column: "NormalizedName", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Presentation", - schema: "Creators"); - - migrationBuilder.DropTable( - name: "Slugs", - schema: "Creators"); - - migrationBuilder.DropTable( - name: "Socials", - schema: "Creators"); - - migrationBuilder.DropTable( - name: "Creators", - schema: "Creators"); - } - } -} diff --git a/backend/Modules/Creators/Migrations/20250610200446_AddStripe.Designer.cs b/backend/Modules/Creators/Migrations/20250610200446_AddStripe.Designer.cs deleted file mode 100644 index 303e74d..0000000 --- a/backend/Modules/Creators/Migrations/20250610200446_AddStripe.Designer.cs +++ /dev/null @@ -1,218 +0,0 @@ -// -using System; -using Hutopy.Modules.Creators.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Creators.Migrations -{ - [DbContext(typeof(CreatorsDbContext))] - [Migration("20250610200446_AddStripe")] - partial class AddStripe - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Creators") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BannerUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("IsStripeChargesEnabled") - .HasColumnType("boolean"); - - b.Property("IsStripeDetailsSubmitted") - .HasColumnType("boolean"); - - b.Property("IsStripePayoutReady") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PortraitUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("StripeAccountId") - .HasMaxLength(21) - .HasColumnType("character varying(21)"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Verified") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.ToTable("Creators", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("NormalizedName") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasComputedColumnSql("LOWER(\"Name\")", true); - - b.Property("ReservedUntil") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Slugs", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b1.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("VideoUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Presentation", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("FacebookUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("InstagramUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("LinkedInUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("RedditUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("TikTokUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("WebsiteUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("XUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("YoutubeUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Socials", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.Navigation("Presentation") - .IsRequired(); - - b.Navigation("Socials") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Creators/Migrations/20250610200446_AddStripe.cs b/backend/Modules/Creators/Migrations/20250610200446_AddStripe.cs deleted file mode 100644 index 14177e5..0000000 --- a/backend/Modules/Creators/Migrations/20250610200446_AddStripe.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Creators.Migrations -{ - /// - public partial class AddStripe : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "AcceptDonation", - schema: "Creators", - table: "Creators"); - - migrationBuilder.RenameColumn( - name: "IsStripeOnboardingComplete", - schema: "Creators", - table: "Creators", - newName: "IsStripeDetailsSubmitted"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "IsStripeDetailsSubmitted", - schema: "Creators", - table: "Creators", - newName: "IsStripeOnboardingComplete"); - - migrationBuilder.AddColumn( - name: "AcceptDonation", - schema: "Creators", - table: "Creators", - type: "boolean", - nullable: false, - defaultValue: false); - } - } -} diff --git a/backend/Modules/Creators/Migrations/CreatorsDbContextModelSnapshot.cs b/backend/Modules/Creators/Migrations/CreatorsDbContextModelSnapshot.cs deleted file mode 100644 index 5c14656..0000000 --- a/backend/Modules/Creators/Migrations/CreatorsDbContextModelSnapshot.cs +++ /dev/null @@ -1,215 +0,0 @@ -// -using System; -using Hutopy.Modules.Creators.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Creators.Migrations -{ - [DbContext(typeof(CreatorsDbContext))] - partial class CreatorsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Creators") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BannerUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("IsDeleted") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("boolean") - .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); - - b.Property("IsStripeChargesEnabled") - .HasColumnType("boolean"); - - b.Property("IsStripeDetailsSubmitted") - .HasColumnType("boolean"); - - b.Property("IsStripePayoutReady") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PortraitUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("StripeAccountId") - .HasMaxLength(21) - .HasColumnType("character varying(21)"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Verified") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.ToTable("Creators", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("NormalizedName") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasComputedColumnSql("LOWER(\"Name\")", true); - - b.Property("ReservedUntil") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique(); - - b.ToTable("Slugs", "Creators"); - }); - - modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b => - { - b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b1.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b1.Property("VideoUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Presentation", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); - - b1.Property("FacebookUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("InstagramUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("LinkedInUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("RedditUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("TikTokUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("WebsiteUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("XUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("YoutubeUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Socials", "Creators"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.Navigation("Presentation") - .IsRequired(); - - b.Navigation("Socials") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Creators/Services/CreatorLookup.cs b/backend/Modules/Creators/Services/CreatorLookup.cs deleted file mode 100644 index 7a0686e..0000000 --- a/backend/Modules/Creators/Services/CreatorLookup.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Services; - -public sealed class CreatorLookup( - CreatorsDbContext context) - : ICreatorLookup -{ - public async Task GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken) - { - Creator? creator = await context - .Creators - .FirstOrDefaultAsync(c => c.Id == creatorId, cancellationToken); - - return creator is null - ? null - : new CreatorReference( - creator.Id, - creator.Name, - creator.PortraitUrl, - creator.IsStripeDetailsSubmitted, - creator.IsStripeChargesEnabled, - creator.StripeAccountId); - } -} diff --git a/backend/Modules/Creators/Services/SlugPurger.cs b/backend/Modules/Creators/Services/SlugPurger.cs deleted file mode 100644 index e605c71..0000000 --- a/backend/Modules/Creators/Services/SlugPurger.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Hutopy.Modules.Creators.Data; - -namespace Hutopy.Modules.Creators.Services; - -public class SlugPurger(CreatorsDbContext context) -{ - private static readonly SemaphoreSlim Semaphore = new(1, 1); - private static DateTimeOffset s_lastPurgeTime = DateTimeOffset.MinValue; - private static readonly TimeSpan MinTimeBetweenPurges = TimeSpan.FromSeconds(10); - - public async Task PurgeExpiredSlugsAsync(CancellationToken ct) - { - // Try to acquire the semaphore - if (!await Semaphore.WaitAsync(0, ct)) - { - // Another purge operation is in progress, skip this one - return; - } - - try - { - DateTimeOffset now = DateTimeOffset.UtcNow; - if (now - s_lastPurgeTime < MinTimeBetweenPurges) - { - // Not enough time has passed since the last purge - return; - } - - // Delete expired slugs that are not in use - await context - .Slugs - .Where(s => s.ReservedUntil < now && s.UsedBy == null) - .ExecuteDeleteAsync(ct); - - // Update the last purge time regardless of whether we found expired slugs or not - s_lastPurgeTime = now; - } - finally - { - Semaphore.Release(); - } - } -} diff --git a/backend/Modules/Identity/Configuration/JwtOptions.cs b/backend/Modules/Identity/Configuration/JwtOptions.cs index 7c4c822..2e3d953 100644 --- a/backend/Modules/Identity/Configuration/JwtOptions.cs +++ b/backend/Modules/Identity/Configuration/JwtOptions.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Configuration; +namespace Socialize.Modules.Identity.Configuration; public record JwtOptions { diff --git a/backend/Modules/Identity/Contracts/IUserLookup.cs b/backend/Modules/Identity/Contracts/IUserLookup.cs index ad0c0ca..3e26bc4 100644 --- a/backend/Modules/Identity/Contracts/IUserLookup.cs +++ b/backend/Modules/Identity/Contracts/IUserLookup.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Contracts; +namespace Socialize.Modules.Identity.Contracts; public interface IUserLookup { diff --git a/backend/Modules/Identity/Contracts/KnownRoles.cs b/backend/Modules/Identity/Contracts/KnownRoles.cs index addad34..110d3e8 100644 --- a/backend/Modules/Identity/Contracts/KnownRoles.cs +++ b/backend/Modules/Identity/Contracts/KnownRoles.cs @@ -1,7 +1,10 @@ -namespace Hutopy.Modules.Identity.Contracts; +namespace Socialize.Modules.Identity.Contracts; public static class KnownRoles { public const string Administrator = nameof(Administrator); - public const string Creator = nameof(Creator); + public const string Manager = nameof(Manager); + public const string Client = nameof(Client); + public const string Provider = nameof(Provider); + public const string WorkspaceMember = nameof(WorkspaceMember); } diff --git a/backend/Modules/Identity/Contracts/UserReference.cs b/backend/Modules/Identity/Contracts/UserReference.cs index f08ce18..cf192f6 100644 --- a/backend/Modules/Identity/Contracts/UserReference.cs +++ b/backend/Modules/Identity/Contracts/UserReference.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Contracts; +namespace Socialize.Modules.Identity.Contracts; public record UserReference( Guid Id, diff --git a/backend/Modules/Identity/Data/IdentityDbContext.cs b/backend/Modules/Identity/Data/IdentityDbContext.cs deleted file mode 100644 index f15ce65..0000000 --- a/backend/Modules/Identity/Data/IdentityDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; - -namespace Hutopy.Modules.Identity.Data; - -public class IdentityDbContext( - DbContextOptions options) - : IdentityDbContext(options) -{ - public const string SchemaName = "Identity"; - - protected override void OnModelCreating(ModelBuilder - modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.HasDefaultSchema(SchemaName); - } -} diff --git a/backend/Modules/Identity/Data/IdentityService.cs b/backend/Modules/Identity/Data/IdentityService.cs index 8521e25..2b127ee 100644 --- a/backend/Modules/Identity/Data/IdentityService.cs +++ b/backend/Modules/Identity/Data/IdentityService.cs @@ -1,7 +1,8 @@ using System.Security.Claims; -using Hutopy.Modules.Identity.Models; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Models; -namespace Hutopy.Modules.Identity.Data; +namespace Socialize.Modules.Identity.Data; public class IdentityService( UserManager userManager, @@ -65,4 +66,23 @@ public class IdentityService( return userRoles; } + + public async Task> GetCurrentUserClaimsAsync() + { + UserModel? currentUserModel = await GetCurrentUserAsync(); + + if (currentUserModel is null) + { + return []; + } + + User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString()); + + if (currentUser is null) + { + return []; + } + + return await userManager.GetClaimsAsync(currentUser); + } } diff --git a/backend/Modules/Identity/Data/Role.cs b/backend/Modules/Identity/Data/Role.cs index ce387a4..f599eea 100644 --- a/backend/Modules/Identity/Data/Role.cs +++ b/backend/Modules/Identity/Data/Role.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Data; +namespace Socialize.Modules.Identity.Data; public class Role : IdentityRole { diff --git a/backend/Modules/Identity/Data/User.cs b/backend/Modules/Identity/Data/User.cs index 2f14f48..678fbde 100644 --- a/backend/Modules/Identity/Data/User.cs +++ b/backend/Modules/Identity/Data/User.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Data; +namespace Socialize.Modules.Identity.Data; public class User : IdentityUser { diff --git a/backend/Modules/Identity/Data/UserManager.cs b/backend/Modules/Identity/Data/UserManager.cs index a8ca648..3b5e2e3 100644 --- a/backend/Modules/Identity/Data/UserManager.cs +++ b/backend/Modules/Identity/Data/UserManager.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Data; +namespace Socialize.Modules.Identity.Data; public sealed class UserManager( IUserStore store, diff --git a/backend/Modules/Identity/DependencyInjection.cs b/backend/Modules/Identity/DependencyInjection.cs index b2b8a1c..15b1e90 100644 --- a/backend/Modules/Identity/DependencyInjection.cs +++ b/backend/Modules/Identity/DependencyInjection.cs @@ -1,19 +1,17 @@ -using Hutopy.Modules.Identity.Configuration; -using Hutopy.Modules.Identity.Contracts; -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Identity.Services; +using Socialize.Data; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Contracts; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity; +namespace Socialize.Modules.Identity; public static class DependencyInjection { public static WebApplicationBuilder AddIdentityModule( - this WebApplicationBuilder builder, - Action? configureAction = null) + this WebApplicationBuilder builder) { - builder.Services.AddDbContext(configureAction); - builder.Services.Configure( builder.Configuration.GetRequiredSection(JwtOptions.SectionName)); @@ -23,10 +21,24 @@ public static class DependencyInjection builder.Services.AddAuthorizationBuilder(); builder.Services + .Configure(options => + { + if (!builder.Environment.IsDevelopment()) + { + return; + } + + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 3; + options.Password.RequiredUniqueChars = 1; + }) .AddIdentityCore() .AddUserManager() .AddRoles() - .AddEntityFrameworkStores() + .AddEntityFrameworkStores() .AddApiEndpoints() .AddDefaultTokenProviders(); @@ -36,6 +48,7 @@ public static class DependencyInjection // Scoped services builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); return builder; @@ -47,9 +60,6 @@ public static class DependencyInjection { IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); using IServiceScope scope = scopeFactory.CreateScope(); - await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - RoleManager roleManager = scope.ServiceProvider.GetRequiredService>(); await TrySeedAsync(roleManager); @@ -64,10 +74,28 @@ public static class DependencyInjection await roleManager.CreateAsync(administratorRole); } - Role roleCreator = new(KnownRoles.Creator); - if (roleManager.Roles.All(r => r.Name != roleCreator.Name)) + Role managerRole = new(KnownRoles.Manager); + if (roleManager.Roles.All(r => r.Name != managerRole.Name)) { - await roleManager.CreateAsync(roleCreator); + await roleManager.CreateAsync(managerRole); + } + + Role clientRole = new(KnownRoles.Client); + if (roleManager.Roles.All(r => r.Name != clientRole.Name)) + { + await roleManager.CreateAsync(clientRole); + } + + Role providerRole = new(KnownRoles.Provider); + if (roleManager.Roles.All(r => r.Name != providerRole.Name)) + { + await roleManager.CreateAsync(providerRole); + } + + Role workspaceMemberRole = new(KnownRoles.WorkspaceMember); + if (roleManager.Roles.All(r => r.Name != workspaceMemberRole.Name)) + { + await roleManager.CreateAsync(workspaceMemberRole); } } } diff --git a/backend/Modules/Identity/Handlers/ChangeAddress.cs b/backend/Modules/Identity/Handlers/ChangeAddress.cs index d586c84..662bdab 100644 --- a/backend/Modules/Identity/Handlers/ChangeAddress.cs +++ b/backend/Modules/Identity/Handlers/ChangeAddress.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangeAddressRequest( diff --git a/backend/Modules/Identity/Handlers/ChangeAlias.cs b/backend/Modules/Identity/Handlers/ChangeAlias.cs index 14c25fd..79dbd84 100644 --- a/backend/Modules/Identity/Handlers/ChangeAlias.cs +++ b/backend/Modules/Identity/Handlers/ChangeAlias.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangeAliasRequest( diff --git a/backend/Modules/Identity/Handlers/ChangeBirthDate.cs b/backend/Modules/Identity/Handlers/ChangeBirthDate.cs index a9c8c55..9935282 100644 --- a/backend/Modules/Identity/Handlers/ChangeBirthDate.cs +++ b/backend/Modules/Identity/Handlers/ChangeBirthDate.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangeBirthDateRequest( diff --git a/backend/Modules/Identity/Handlers/ChangeEmail.cs b/backend/Modules/Identity/Handlers/ChangeEmail.cs index e9d4349..4cc845e 100644 --- a/backend/Modules/Identity/Handlers/ChangeEmail.cs +++ b/backend/Modules/Identity/Handlers/ChangeEmail.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangeEmailRequest( diff --git a/backend/Modules/Identity/Handlers/ChangeFullname.cs b/backend/Modules/Identity/Handlers/ChangeFullname.cs index 40bb5ec..53d40ae 100644 --- a/backend/Modules/Identity/Handlers/ChangeFullname.cs +++ b/backend/Modules/Identity/Handlers/ChangeFullname.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangeFullnameRequest( diff --git a/backend/Modules/Identity/Handlers/ChangePhone.cs b/backend/Modules/Identity/Handlers/ChangePhone.cs index fe93120..5d25d93 100644 --- a/backend/Modules/Identity/Handlers/ChangePhone.cs +++ b/backend/Modules/Identity/Handlers/ChangePhone.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangePhoneRequest( diff --git a/backend/Modules/Identity/Handlers/ChangePortrait.cs b/backend/Modules/Identity/Handlers/ChangePortrait.cs index b6f0c61..e5f4e6c 100644 --- a/backend/Modules/Identity/Handlers/ChangePortrait.cs +++ b/backend/Modules/Identity/Handlers/ChangePortrait.cs @@ -1,9 +1,9 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.BlobStorage.Contracts; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ChangePortraitRequest( diff --git a/backend/Modules/Identity/Handlers/ForgotPassword.cs b/backend/Modules/Identity/Handlers/ForgotPassword.cs index 7618059..061be8b 100644 --- a/backend/Modules/Identity/Handlers/ForgotPassword.cs +++ b/backend/Modules/Identity/Handlers/ForgotPassword.cs @@ -1,10 +1,10 @@ using System.Web; -using Hutopy.Infrastructure.Configuration; -using Hutopy.Infrastructure.Emailer.Contracts; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Configuration; +using Socialize.Infrastructure.Emailer.Contracts; +using Socialize.Modules.Identity.Data; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ForgotPasswordRequest( @@ -49,10 +49,10 @@ public class ForgotPasswordHandler( $"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}"; // Create a styled email message - string subject = "Reset your Hutopy password"; + string subject = "Reset your Socialize password"; string message = $"""
-

Reset Your Hutopy Password

+

Reset Your Socialize Password

Please click the button below to reset your password: diff --git a/backend/Modules/Identity/Handlers/GetCurrentUser.cs b/backend/Modules/Identity/Handlers/GetCurrentUser.cs index eff14d3..9a75372 100644 --- a/backend/Modules/Identity/Handlers/GetCurrentUser.cs +++ b/backend/Modules/Identity/Handlers/GetCurrentUser.cs @@ -1,7 +1,9 @@ -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Identity.Models; +using System.Security.Claims; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Models; +using Socialize.Infrastructure.Security; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public class GetCurrentUserQueryHandler( @@ -26,11 +28,42 @@ public class GetCurrentUserQueryHandler( } IList roles = await identityService.GetCurrentUserRolesAsync(); + IList claims = await identityService.GetCurrentUserClaimsAsync(); + + string? persona = claims + .Where(claim => claim.Type == KnownClaims.Persona) + .Select(claim => claim.Value) + .LastOrDefault(); + + List workspaceIds = claims + .Where(claim => claim.Type == KnownClaims.WorkspaceScope) + .Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + List clientIds = claims + .Where(claim => claim.Type == KnownClaims.ClientScope) + .Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + List projectIds = claims + .Where(claim => claim.Type == KnownClaims.ProjectScope) + .Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); await SendOkAsync( new UserDto { Id = userModel.Id, + Persona = persona, + AuthorizedWorkspaceIds = workspaceIds, + AuthorizedClientIds = clientIds, + AuthorizedProjectIds = projectIds, Alias = userModel.Alias, PortraitUrl = userModel.PortraitUrl, Firstname = userModel.Firstname, diff --git a/backend/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs b/backend/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs index 71d8732..5836847 100644 --- a/backend/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs +++ b/backend/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.BlobStorage.Contracts; -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Identity.Models; +using Socialize.Infrastructure.BlobStorage.Contracts; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Models; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public class GetCurrentUserPortraitHandler( diff --git a/backend/Modules/Identity/Handlers/Login.cs b/backend/Modules/Identity/Handlers/Login.cs index 2224647..398292c 100644 --- a/backend/Modules/Identity/Handlers/Login.cs +++ b/backend/Modules/Identity/Handlers/Login.cs @@ -1,9 +1,10 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Configuration; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Services; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record LoginRequest( @@ -18,7 +19,8 @@ public record LoginResponse( [PublicAPI] public class LoginHandler( UserManager userManager, - IOptionsSnapshot jwtOptions) + IOptionsSnapshot jwtOptions, + AccessTokenFactory accessTokenFactory) : Endpoint { public override void Configure() @@ -34,6 +36,7 @@ public class LoginHandler( { // Find the user by email User? user = await userManager.FindByEmailAsync(request.Email); + user ??= await userManager.FindByNameAsync(request.Email); if (user is null) { await SendStringAsync( @@ -70,17 +73,7 @@ public class LoginHandler( await userManager.UpdateAsync(user); // Generate JWT token - string accessToken = JwtTokenHelper.GenerateJwtToken( - jwtOptions.Value.Lifetime, - jwtOptions.Value.Issuer, - jwtOptions.Value.Audience, - jwtOptions.Value.Key, - user.Id.ToString(), - user.Email ?? string.Empty, - user.Alias, - user.Firstname ?? string.Empty, - user.Lastname ?? string.Empty, - user.PortraitUrl); + string accessToken = await accessTokenFactory.CreateAsync(user); await SendOkAsync( new LoginResponse(accessToken, user.RefreshToken), diff --git a/backend/Modules/Identity/Handlers/LoginWithFacebook.cs b/backend/Modules/Identity/Handlers/LoginWithFacebook.cs index c4a0a4f..b3f851c 100644 --- a/backend/Modules/Identity/Handlers/LoginWithFacebook.cs +++ b/backend/Modules/Identity/Handlers/LoginWithFacebook.cs @@ -1,12 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Configuration; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public class FacebookUserInfo @@ -42,7 +43,8 @@ public record LoginWithFacebookResponse( public class LoginWithFacebookHandler( IHttpClientFactory httpClientFactory, UserManager userManager, - IOptionsSnapshot jwtOptions) + IOptionsSnapshot jwtOptions, + AccessTokenFactory accessTokenFactory) : Endpoint { public override void Configure() @@ -124,17 +126,7 @@ public class LoginWithFacebookHandler( user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); await userManager.UpdateAsync(user); - string accessToken = JwtTokenHelper.GenerateJwtToken( - jwtOptions.Value.Lifetime, - jwtOptions.Value.Issuer, - jwtOptions.Value.Audience, - jwtOptions.Value.Key, - user.Id.ToString(), - user.Email ?? string.Empty, - user.Alias, - user.Firstname ?? string.Empty, - user.Lastname ?? string.Empty, - user.PortraitUrl); + string accessToken = await accessTokenFactory.CreateAsync(user); await SendOkAsync( new LoginWithFacebookResponse(accessToken, refreshToken), diff --git a/backend/Modules/Identity/Handlers/LoginWithGoogle.cs b/backend/Modules/Identity/Handlers/LoginWithGoogle.cs index 46cb0f1..20defc1 100644 --- a/backend/Modules/Identity/Handlers/LoginWithGoogle.cs +++ b/backend/Modules/Identity/Handlers/LoginWithGoogle.cs @@ -1,12 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Configuration; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; internal class GoogleToken { @@ -42,7 +43,8 @@ public record LoginWithGoogleResponse( public class LoginWithGoogleHandler( IHttpClientFactory httpClientFactory, UserManager userManager, - IOptionsSnapshot jwtOptions) + IOptionsSnapshot jwtOptions, + AccessTokenFactory accessTokenFactory) : Endpoint { public override void Configure() @@ -128,17 +130,7 @@ public class LoginWithGoogleHandler( user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); await userManager.UpdateAsync(user); - string accessToken = JwtTokenHelper.GenerateJwtToken( - jwtOptions.Value.Lifetime, - jwtOptions.Value.Issuer, - jwtOptions.Value.Audience, - jwtOptions.Value.Key, - user.Id.ToString(), - user.Email ?? string.Empty, - user.Alias, - user.Firstname ?? string.Empty, - user.Lastname ?? string.Empty, - user.PortraitUrl); + string accessToken = await accessTokenFactory.CreateAsync(user); await SendOkAsync( new LoginWithGoogleResponse(accessToken, user.RefreshToken), diff --git a/backend/Modules/Identity/Handlers/RefreshToken.cs b/backend/Modules/Identity/Handlers/RefreshToken.cs index 2950509..b40870d 100644 --- a/backend/Modules/Identity/Handlers/RefreshToken.cs +++ b/backend/Modules/Identity/Handlers/RefreshToken.cs @@ -1,9 +1,10 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Configuration; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record RefreshTokenRequest( @@ -17,7 +18,8 @@ public record RefreshTokenResponse( [PublicAPI] public class RefreshTokenHandler( UserManager userManager, - IOptionsSnapshot jwtOptions) + IOptionsSnapshot jwtOptions, + AccessTokenFactory accessTokenFactory) : Endpoint { public override void Configure() @@ -52,17 +54,7 @@ public class RefreshTokenHandler( await userManager.UpdateAsync(user); // Generate a new access token - string accessToken = JwtTokenHelper.GenerateJwtToken( - jwtOptions.Value.Lifetime, - jwtOptions.Value.Issuer, - jwtOptions.Value.Audience, - jwtOptions.Value.Key, - user.Id.ToString(), - user.Email ?? string.Empty, - user.Alias, - user.Firstname ?? string.Empty, - user.Lastname ?? string.Empty, - user.PortraitUrl); + string accessToken = await accessTokenFactory.CreateAsync(user); await SendOkAsync( new RefreshTokenResponse(accessToken, user.RefreshToken), diff --git a/backend/Modules/Identity/Handlers/Register.cs b/backend/Modules/Identity/Handlers/Register.cs index 1e0bad1..1c7be79 100644 --- a/backend/Modules/Identity/Handlers/Register.cs +++ b/backend/Modules/Identity/Handlers/Register.cs @@ -1,8 +1,8 @@ -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Identity.Services; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record RegisterRequest( diff --git a/backend/Modules/Identity/Handlers/ResendVerification.cs b/backend/Modules/Identity/Handlers/ResendVerification.cs index cd9b79e..14d9429 100644 --- a/backend/Modules/Identity/Handlers/ResendVerification.cs +++ b/backend/Modules/Identity/Handlers/ResendVerification.cs @@ -1,7 +1,7 @@ -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Identity.Services; +using Socialize.Modules.Identity.Data; +using Socialize.Modules.Identity.Services; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ResendVerificationRequest( diff --git a/backend/Modules/Identity/Handlers/ResetPassword.cs b/backend/Modules/Identity/Handlers/ResetPassword.cs index d1286da..2e933f0 100644 --- a/backend/Modules/Identity/Handlers/ResetPassword.cs +++ b/backend/Modules/Identity/Handlers/ResetPassword.cs @@ -1,7 +1,7 @@ -using Hutopy.Modules.Identity.Data; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record ResetPasswordRequest( diff --git a/backend/Modules/Identity/Handlers/SetPassword.cs b/backend/Modules/Identity/Handlers/SetPassword.cs index 2e80c1c..c2d533d 100644 --- a/backend/Modules/Identity/Handlers/SetPassword.cs +++ b/backend/Modules/Identity/Handlers/SetPassword.cs @@ -1,8 +1,8 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record SetPasswordRequest( diff --git a/backend/Modules/Identity/Handlers/VerifyEmail.cs b/backend/Modules/Identity/Handlers/VerifyEmail.cs index 0cce005..febc059 100644 --- a/backend/Modules/Identity/Handlers/VerifyEmail.cs +++ b/backend/Modules/Identity/Handlers/VerifyEmail.cs @@ -1,8 +1,8 @@ using System.Web; -using Hutopy.Modules.Identity.Data; +using Socialize.Modules.Identity.Data; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity.Handlers; +namespace Socialize.Modules.Identity.Handlers; [PublicAPI] public record VerifyEmailRequest( diff --git a/backend/Modules/Identity/IdentityResultExtensions.cs b/backend/Modules/Identity/IdentityResultExtensions.cs index a07ee1a..0467a71 100644 --- a/backend/Modules/Identity/IdentityResultExtensions.cs +++ b/backend/Modules/Identity/IdentityResultExtensions.cs @@ -1,7 +1,7 @@ -using Hutopy.Modules.Identity.Models; +using Socialize.Modules.Identity.Models; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Modules.Identity; +namespace Socialize.Modules.Identity; public static class IdentityResultExtensions { diff --git a/backend/Modules/Identity/Migrations/20250609203622_Initial.Designer.cs b/backend/Modules/Identity/Migrations/20250609203622_Initial.Designer.cs deleted file mode 100644 index e64669d..0000000 --- a/backend/Modules/Identity/Migrations/20250609203622_Initial.Designer.cs +++ /dev/null @@ -1,315 +0,0 @@ -// -using System; -using Hutopy.Modules.Identity.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Identity.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20250609203622_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Identity") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Identity.Data.Role", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "Identity"); - }); - - modelBuilder.Entity("Hutopy.Modules.Identity.Data.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("Address") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Alias") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("BirthDate") - .HasColumnType("timestamp with time zone"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("FacebookId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Firstname") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("GoogleId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Lastname") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("PortraitUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("RefreshToken") - .HasMaxLength(44) - .HasColumnType("character varying(44)"); - - b.Property("RefreshTokenExpiryTime") - .HasColumnType("timestamp with time zone"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("ProviderKey") - .HasColumnType("text"); - - b.Property("ProviderDisplayName") - .HasColumnType("text"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("Name") - .HasColumnType("text"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.Role", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.Role", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Identity/Migrations/20250609203622_Initial.cs b/backend/Modules/Identity/Migrations/20250609203622_Initial.cs deleted file mode 100644 index 7bc43bf..0000000 --- a/backend/Modules/Identity/Migrations/20250609203622_Initial.cs +++ /dev/null @@ -1,263 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Identity.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Identity"); - - migrationBuilder.CreateTable( - name: "AspNetRoles", - schema: "Identity", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - schema: "Identity", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Alias = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Firstname = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Lastname = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - BirthDate = table.Column(type: "timestamp with time zone", nullable: true), - Address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - PortraitUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), - GoogleId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - FacebookId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - RefreshToken = table.Column(type: "character varying(44)", maxLength: 44, nullable: true), - RefreshTokenExpiryTime = table.Column(type: "timestamp with time zone", nullable: false), - UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "boolean", nullable: false), - PasswordHash = table.Column(type: "text", nullable: true), - SecurityStamp = table.Column(type: "text", nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true), - PhoneNumber = table.Column(type: "text", nullable: true), - PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), - TwoFactorEnabled = table.Column(type: "boolean", nullable: false), - LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), - LockoutEnabled = table.Column(type: "boolean", nullable: false), - AccessFailedCount = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - schema: "Identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RoleId = table.Column(type: "uuid", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalSchema: "Identity", - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - schema: "Identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "uuid", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "Identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - schema: "Identity", - columns: table => new - { - LoginProvider = table.Column(type: "text", nullable: false), - ProviderKey = table.Column(type: "text", nullable: false), - ProviderDisplayName = table.Column(type: "text", nullable: true), - UserId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "Identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - schema: "Identity", - columns: table => new - { - UserId = table.Column(type: "uuid", nullable: false), - RoleId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalSchema: "Identity", - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "Identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - schema: "Identity", - columns: table => new - { - UserId = table.Column(type: "uuid", nullable: false), - LoginProvider = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Value = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "Identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - schema: "Identity", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - schema: "Identity", - table: "AspNetRoles", - column: "NormalizedName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - schema: "Identity", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - schema: "Identity", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - schema: "Identity", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - schema: "Identity", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - schema: "Identity", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetRoles", - schema: "Identity"); - - migrationBuilder.DropTable( - name: "AspNetUsers", - schema: "Identity"); - } - } -} diff --git a/backend/Modules/Identity/Migrations/IdentityDbContextModelSnapshot.cs b/backend/Modules/Identity/Migrations/IdentityDbContextModelSnapshot.cs deleted file mode 100644 index f22b773..0000000 --- a/backend/Modules/Identity/Migrations/IdentityDbContextModelSnapshot.cs +++ /dev/null @@ -1,312 +0,0 @@ -// -using System; -using Hutopy.Modules.Identity.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Identity.Migrations -{ - [DbContext(typeof(IdentityDbContext))] - partial class IdentityDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Identity") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Identity.Data.Role", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "Identity"); - }); - - modelBuilder.Entity("Hutopy.Modules.Identity.Data.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("Address") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Alias") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("BirthDate") - .HasColumnType("timestamp with time zone"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("FacebookId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Firstname") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("GoogleId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Lastname") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("PortraitUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("RefreshToken") - .HasMaxLength(44) - .HasColumnType("character varying(44)"); - - b.Property("RefreshTokenExpiryTime") - .HasColumnType("timestamp with time zone"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("ProviderKey") - .HasColumnType("text"); - - b.Property("ProviderDisplayName") - .HasColumnType("text"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("Name") - .HasColumnType("text"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", "Identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.Role", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.Role", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Hutopy.Modules.Identity.Data.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Identity/Models/Result.cs b/backend/Modules/Identity/Models/Result.cs index 960b530..e20ce55 100644 --- a/backend/Modules/Identity/Models/Result.cs +++ b/backend/Modules/Identity/Models/Result.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Models; +namespace Socialize.Modules.Identity.Models; public class Result( bool succeeded, diff --git a/backend/Modules/Identity/Models/RoleModel.cs b/backend/Modules/Identity/Models/RoleModel.cs index 060e6ec..e59cbc1 100644 --- a/backend/Modules/Identity/Models/RoleModel.cs +++ b/backend/Modules/Identity/Models/RoleModel.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Models; +namespace Socialize.Modules.Identity.Models; public class RoleModel { diff --git a/backend/Modules/Identity/Models/UserDto.cs b/backend/Modules/Identity/Models/UserDto.cs index df3da49..76e648c 100644 --- a/backend/Modules/Identity/Models/UserDto.cs +++ b/backend/Modules/Identity/Models/UserDto.cs @@ -1,9 +1,13 @@ -namespace Hutopy.Modules.Identity.Models; +namespace Socialize.Modules.Identity.Models; public class UserDto { public Guid Id { get; init; } public IList UserRoles { get; init; } = []; + public string? Persona { get; init; } + public IList AuthorizedWorkspaceIds { get; init; } = []; + public IList AuthorizedClientIds { get; init; } = []; + public IList AuthorizedProjectIds { get; init; } = []; public string Username { get; init; } = null!; public string? Alias { get; init; } public string? PortraitUrl { get; init; } diff --git a/backend/Modules/Identity/Models/UserModel.cs b/backend/Modules/Identity/Models/UserModel.cs index e06a780..5ba16ba 100644 --- a/backend/Modules/Identity/Models/UserModel.cs +++ b/backend/Modules/Identity/Models/UserModel.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Modules.Identity.Models; +namespace Socialize.Modules.Identity.Models; public class UserModel { diff --git a/backend/Modules/Identity/Services/AccessTokenFactory.cs b/backend/Modules/Identity/Services/AccessTokenFactory.cs new file mode 100644 index 0000000..de801bc --- /dev/null +++ b/backend/Modules/Identity/Services/AccessTokenFactory.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Configuration; +using Socialize.Modules.Identity.Contracts; +using Socialize.Modules.Identity.Data; +using Microsoft.Extensions.Options; + +namespace Socialize.Modules.Identity.Services; + +public sealed class AccessTokenFactory( + UserManager userManager, + IOptionsSnapshot jwtOptions) +{ + public async Task CreateAsync(User user) + { + IList roles = await userManager.GetRolesAsync(user); + IList claims = await userManager.GetClaimsAsync(user); + + string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) + ? KnownRoles.Manager + : roles.Contains(KnownRoles.Client, StringComparer.Ordinal) + ? KnownRoles.Client + : roles.Contains(KnownRoles.Provider, StringComparer.Ordinal) + ? KnownRoles.Provider + : KnownRoles.WorkspaceMember; + + List tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)]; + + return JwtTokenHelper.GenerateJwtToken( + jwtOptions.Value.Lifetime, + jwtOptions.Value.Issuer, + jwtOptions.Value.Audience, + jwtOptions.Value.Key, + user.Id.ToString(), + user.Email ?? string.Empty, + user.Alias, + user.Firstname ?? string.Empty, + user.Lastname ?? string.Empty, + user.PortraitUrl, + roles, + tokenClaims); + } +} diff --git a/backend/Modules/Identity/Services/EmailVerificationService.cs b/backend/Modules/Identity/Services/EmailVerificationService.cs index 2440bbf..f1a455d 100644 --- a/backend/Modules/Identity/Services/EmailVerificationService.cs +++ b/backend/Modules/Identity/Services/EmailVerificationService.cs @@ -1,10 +1,10 @@ using System.Web; -using Hutopy.Infrastructure.Configuration; -using Hutopy.Infrastructure.Emailer.Contracts; -using Hutopy.Modules.Identity.Data; +using Socialize.Infrastructure.Configuration; +using Socialize.Infrastructure.Emailer.Contracts; +using Socialize.Modules.Identity.Data; using Microsoft.Extensions.Options; -namespace Hutopy.Modules.Identity.Services; +namespace Socialize.Modules.Identity.Services; [PublicAPI] public sealed class EmailVerificationService( @@ -26,7 +26,7 @@ public sealed class EmailVerificationService( "Verify your email address", $"""

-

Welcome to Hutopy!

+

Welcome to Socialize!

Please verify your email address by clicking the button below: diff --git a/backend/Modules/Identity/Services/UserLookup.cs b/backend/Modules/Identity/Services/UserLookup.cs index f5315e4..b8f7892 100644 --- a/backend/Modules/Identity/Services/UserLookup.cs +++ b/backend/Modules/Identity/Services/UserLookup.cs @@ -1,7 +1,7 @@ -using Hutopy.Modules.Identity.Contracts; -using Hutopy.Modules.Identity.Data; +using Socialize.Modules.Identity.Contracts; +using Socialize.Modules.Identity.Data; -namespace Hutopy.Modules.Identity.Services; +namespace Socialize.Modules.Identity.Services; public sealed class UserLookup( UserManager userManager) diff --git a/backend/Modules/Memberships/Contracts/IMembershipCancellationProcessor.cs b/backend/Modules/Memberships/Contracts/IMembershipCancellationProcessor.cs deleted file mode 100644 index 9fbcf7d..0000000 --- a/backend/Modules/Memberships/Contracts/IMembershipCancellationProcessor.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Modules.Memberships.Contracts; - -public interface IMembershipCancellationProcessor -{ - Task CancelAsync(string subscriptionId, CancellationToken ct = default); -} diff --git a/backend/Modules/Memberships/Contracts/IMembershipNotifier.cs b/backend/Modules/Memberships/Contracts/IMembershipNotifier.cs deleted file mode 100644 index 31aacab..0000000 --- a/backend/Modules/Memberships/Contracts/IMembershipNotifier.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Hutopy.Modules.Memberships.Contracts; - -public interface IMembershipNotifier -{ - Task NotifyCheckoutSessionCompleted(string stripeSessionId, string stripeSubscriptionId, - string userId, - string creatorId, - string tierId, - CancellationToken cancellationToken = default); - - Task NotifyPaymentSucceedAsync( - string stripeSubscriptionId, - string hostedInvoiceUrl, - decimal amount, - string currency, - CancellationToken cancellationToken = default); - - Task NotifySubscriptionUpdatedAsync( - string subscriptionId, - DateTimeOffset? endDate, - CancellationToken cancellationToken = default); - - Task NotifySubscriptionDeletedAsync( - string subscriptionId, - CancellationToken cancellationToken = default); -} diff --git a/backend/Modules/Memberships/Contracts/IMembershipPaymentProcessor.cs b/backend/Modules/Memberships/Contracts/IMembershipPaymentProcessor.cs deleted file mode 100644 index 49a4adb..0000000 --- a/backend/Modules/Memberships/Contracts/IMembershipPaymentProcessor.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Hutopy.Modules.Creators.Contracts; - -namespace Hutopy.Modules.Memberships.Contracts; - -public interface IMembershipPaymentProcessor -{ - Task CreateCheckoutSessionAsync( - Guid userId, - CreatorReference creatorReference, - Guid tierId, - string priceId, - string successUrl, - string cancelUrl); -} diff --git a/backend/Modules/Memberships/Contracts/IMembershipTierProcessor.cs b/backend/Modules/Memberships/Contracts/IMembershipTierProcessor.cs deleted file mode 100644 index d6f1ff4..0000000 --- a/backend/Modules/Memberships/Contracts/IMembershipTierProcessor.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Hutopy.Modules.Memberships.Contracts; - -public interface IMembershipTierProcessor -{ - Task CreateAsync( - Guid creatorId, - Guid tierId, - string productName, - string currencyCode, - decimal amount); -} diff --git a/backend/Modules/Memberships/Contracts/MembershipCheckoutSession.cs b/backend/Modules/Memberships/Contracts/MembershipCheckoutSession.cs deleted file mode 100644 index 748eb0b..0000000 --- a/backend/Modules/Memberships/Contracts/MembershipCheckoutSession.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Modules.Memberships.Contracts; - -[PublicAPI] -public record MembershipCheckoutSession( - string Id, - string Url); diff --git a/backend/Modules/Memberships/Data/Membership.cs b/backend/Modules/Memberships/Data/Membership.cs deleted file mode 100644 index 3daceba..0000000 --- a/backend/Modules/Memberships/Data/Membership.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Memberships.Data; - -public class Membership -{ - public Guid Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public Guid UserId { get; set; } - public Guid CreatorId { get; set; } - public Guid TierId { get; set; } - public MembershipTier? MembershipTier { get; set; } - public MembershipState State { get; set; } - public DateTimeOffset? StartDate { get; set; } - public DateTimeOffset? EndDate { get; set; } - public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow; - [MaxLength(256)] public string? StripeSubscriptionId { get; set; } - - public ICollection Payments { get; set; } = []; -} diff --git a/backend/Modules/Memberships/Data/MembershipState.cs b/backend/Modules/Memberships/Data/MembershipState.cs deleted file mode 100644 index 63c48bb..0000000 --- a/backend/Modules/Memberships/Data/MembershipState.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Hutopy.Modules.Memberships.Data; - -public enum MembershipState -{ - Pending, - Active, - Inactive -} diff --git a/backend/Modules/Memberships/Data/MembershipTier.cs b/backend/Modules/Memberships/Data/MembershipTier.cs deleted file mode 100644 index ee4b8b3..0000000 --- a/backend/Modules/Memberships/Data/MembershipTier.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Hutopy.Common.Domain; - -namespace Hutopy.Modules.Memberships.Data; - -public class MembershipTier : Entity -{ - public Guid CreatorId { get; set; } - [MaxLength(128)] public string Name { get; set; } = null!; - [MaxLength(4096)] public string Description { get; set; } = null!; - public decimal Price { get; set; } - [MaxLength(128)] public string CurrencyCode { get; set; } = null!; - [MaxLength(128)] public string StripeProductId { get; set; } = null!; - [MaxLength(128)] public string StripePriceId { get; set; } = null!; - - public ICollection Subscriptions { get; set; } = []; -} diff --git a/backend/Modules/Memberships/Data/MembershipsDbContext.cs b/backend/Modules/Memberships/Data/MembershipsDbContext.cs deleted file mode 100644 index f5f1190..0000000 --- a/backend/Modules/Memberships/Data/MembershipsDbContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Hutopy.Modules.Memberships.Data; - -public sealed class MembershipsDbContext( - DbContextOptions options) - : DbContext(options) -{ - public const string SchemaName = "Memberships"; - - public DbSet MembershipTiers => Set(); - public DbSet Memberships => Set(); - public DbSet Payments => Set(); - - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - } -} diff --git a/backend/Modules/Memberships/Data/Payment.cs b/backend/Modules/Memberships/Data/Payment.cs deleted file mode 100644 index eda0325..0000000 --- a/backend/Modules/Memberships/Data/Payment.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Hutopy.Modules.Memberships.Data; - -public class Payment -{ - public Guid Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public decimal Amount { get; set; } - [MaxLength(8)] public required string Currency { get; set; } - [MaxLength(2048)] public required string InvoiceUrl { get; set; } -} diff --git a/backend/Modules/Memberships/DependencyInjection.cs b/backend/Modules/Memberships/DependencyInjection.cs deleted file mode 100644 index 1edfc8f..0000000 --- a/backend/Modules/Memberships/DependencyInjection.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Memberships.Data; -using Hutopy.Modules.Memberships.Services; - -namespace Hutopy.Modules.Memberships; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddMembershipModule( - this WebApplicationBuilder builder, - Action? configureAction = null) - { - builder.Services.AddDbContext(configureAction); - - builder.Services.AddTransient(); - - return builder; - } - - - public static async Task UseMembershipModuleAsync( - this IApplicationBuilder app, - CancellationToken cancellationToken = default) - { - IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); - using IServiceScope scope = scopeFactory.CreateScope(); - await using MembershipsDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - - return app; - } -} diff --git a/backend/Modules/Memberships/Handlers/CancelMembership.cs b/backend/Modules/Memberships/Handlers/CancelMembership.cs deleted file mode 100644 index e093048..0000000 --- a/backend/Modules/Memberships/Handlers/CancelMembership.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Handlers; - -[PublicAPI] -public class CancelMembershipRequest -{ - public Guid SubscriptionId { get; set; } -} - -public class CancelMembershipHandler( - MembershipsDbContext dbContext, - IMembershipCancellationProcessor cancellationProcessor) - : Endpoint -{ - public override void Configure() - { - Delete("/api/memberships"); - Options(o => o.WithTags("Memberships")); - } - - public override async Task HandleAsync( - CancelMembershipRequest req, - CancellationToken ct) - { - Membership? subscription = await dbContext - .Memberships - .FindAsync( - [req.SubscriptionId], - ct); - - if (subscription is not { EndDate: null } - || subscription.StripeSubscriptionId is null) - { - await SendNotFoundAsync(ct); - return; - } - - // Cancel Stripe subscription - await cancellationProcessor.CancelAsync(subscription.StripeSubscriptionId, ct); - - // Update subscription in the system - subscription.EndDate = DateTime.UtcNow; - await dbContext.SaveChangesAsync(ct); - - await SendOkAsync(subscription.Id, ct); - } -} diff --git a/backend/Modules/Memberships/Handlers/CreateMembershipTier.cs b/backend/Modules/Memberships/Handlers/CreateMembershipTier.cs deleted file mode 100644 index 741ee26..0000000 --- a/backend/Modules/Memberships/Handlers/CreateMembershipTier.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Handlers; - -[PublicAPI] -public record struct CreateMembershipTierRequest( - Guid CreatorId, - string Name, - string Description, - decimal Price, - string Currency = "CAD"); - -[PublicAPI] -public class CreateMembershipTierEndpoint( - MembershipsDbContext dbContext, - IMembershipTierProcessor membershipTierProcessor) - : Endpoint -{ - public override void Configure() - { - Post("/api/memberships/tiers"); - Options(o => o.WithTags("Memberships")); - } - - public override async Task HandleAsync( - CreateMembershipTierRequest req, - CancellationToken ct) - { - Guid tierId = Guid.CreateVersion7(); - - string productId = await membershipTierProcessor.CreateAsync( - req.CreatorId, - tierId, - req.Name, - req.Currency, - req.Price); - - // Record the new Tier - MembershipTier tier = new() - { - Id = tierId, - CreatorId = req.CreatorId, - Price = req.Price, - Name = req.Name, - Description = req.Description, - StripeProductId = productId - }; - - dbContext.MembershipTiers.Add(tier); - - await dbContext.SaveChangesAsync(ct); - - await SendOkAsync(tier, ct); - } -} diff --git a/backend/Modules/Memberships/Handlers/GetActiveMemberships.cs b/backend/Modules/Memberships/Handlers/GetActiveMemberships.cs deleted file mode 100644 index 346c13a..0000000 --- a/backend/Modules/Memberships/Handlers/GetActiveMemberships.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Handlers; - -[PublicAPI] -public record struct GetActiveMembershipsResponse( - Guid Id, - Guid CreatorId, - string CreatorName, - string CreatorPortraitUrl, - DateTimeOffset? StartDate, - DateTimeOffset? EndDate); - -[PublicAPI] -public class GetActiveMembershipsHandler( - ICreatorLookup creatorLookup, - MembershipsDbContext dbContext) - : EndpointWithoutRequest> -{ - public override void Configure() - { - Get("/api/memberships/active"); - Options(o => o.WithTags("Memberships")); - } - - public override async Task HandleAsync( - CancellationToken ct) - { - List subscriptions = await dbContext - .Memberships - .Where(subscription => subscription.UserId == User.GetUserId()) - .Where(subscription => subscription.State == MembershipState.Active) - .ToListAsync(ct); - - GetActiveMembershipsResponse[] result = await Task.WhenAll( - subscriptions.Select(async subscription => - { - CreatorReference? creator = await creatorLookup.GetCreatorAsync(subscription.CreatorId, ct); - - return new GetActiveMembershipsResponse( - subscription.Id, - subscription.CreatorId, - creator?.Name ?? "Unknown Creator", - creator?.PortraitUrl ?? string.Empty, - subscription.StartDate, - subscription.EndDate); - })); - - - await SendOkAsync(result, ct); - } -} diff --git a/backend/Modules/Memberships/Handlers/GetMembershipTiers.cs b/backend/Modules/Memberships/Handlers/GetMembershipTiers.cs deleted file mode 100644 index cd44a1b..0000000 --- a/backend/Modules/Memberships/Handlers/GetMembershipTiers.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Handlers; - -[PublicAPI] -public record GetMembershipTiersRequest -{ - public Guid CreatorId { get; set; } -} - -[PublicAPI] -public record struct TierModel( - Guid Id, - DateTimeOffset CreatedAt, - string Name, - string Description, - decimal Price, - string CurrencyCode, - string StripeProductId); - -[PublicAPI] -public class GetMembershipTiersEndpoint( - MembershipsDbContext dbContext) - : Endpoint> -{ - public override void Configure() - { - Get("/api/memberships/tiers/{CreatorId:guid}"); - Options(o => o.WithTags("Memberships")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetMembershipTiersRequest req, - CancellationToken ct) - { - List tiers = await dbContext - .MembershipTiers - .Where(tier => tier.CreatorId == req.CreatorId) - .Select(tier => new TierModel( - tier.Id, - tier.CreatedAt, - tier.Name, - tier.Description, - tier.Price, - tier.CurrencyCode, - tier.StripeProductId)) - .ToListAsync(ct); - - await SendOkAsync(tiers, ct); - } -} diff --git a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs b/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs deleted file mode 100644 index 969fce5..0000000 --- a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Diagnostics; -using Hutopy.Infrastructure.Payments.Stripe.Configuration; -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Tipping.Contracts; -using Microsoft.Extensions.Options; -using Stripe; -using Stripe.Checkout; - -namespace Hutopy.Modules.Memberships.Handlers; - -internal class StripeWebhookEndpoint( - ITipPaymentNotifier tipPaymentNotifier, - IMembershipNotifier membershipNotifier, - IOptions stripeOptions) - : EndpointWithoutRequest -{ - public override void Configure() - { - Post("/api/stripe"); - AllowAnonymous(); - Options(o => o.WithTags("Webhooks")); - } - - public override async Task HandleAsync(CancellationToken ct) - { - var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; - using StreamReader streamReader = new(HttpContext.Request.Body); - - var json = await streamReader.ReadToEndAsync(ct).ConfigureAwait(false); - - var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, stripeOptions.Value.WebhookSecret); - - var stripeSession = stripeEvent.Data.Object as Session; - var stripeSubscription = stripeEvent.Data.Object as Subscription; - - switch (stripeEvent.Type) - { - case "checkout.session.completed": - Debug.Assert(stripeSession != null); - switch (stripeSession.Mode) - { - // Check if this is a one-time tip - case "payment" when stripeSession is { PaymentIntentId: not null, PaymentStatus: "paid" }: - // Get the customer email from the appropriate place - var customerEmail = stripeSession.CustomerDetails?.Email ?? - stripeSession.Customer?.Email ?? - ""; - - StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; - var paymentIntentService = new PaymentIntentService(); - var paymentIntent = await paymentIntentService - .GetAsync( - stripeSession.PaymentIntentId, - new PaymentIntentGetOptions { Expand = ["latest_charge"] }, - cancellationToken: ct) - .ConfigureAwait(false); - var receiptUrl = paymentIntent.LatestCharge.ReceiptUrl; - var receiptUri = new Uri(receiptUrl); - - // Get the receipt URL, preferring the one directly on the charge if available - await tipPaymentNotifier - .NotifyPaymentSucceedAsync( - stripeSession.Id, - receiptUri, - customerEmail, - ct) - .ConfigureAwait(false); - break; - - // Check if this is a subscription - case "subscription" when stripeSession.SubscriptionId != null: - await membershipNotifier - .NotifyPaymentSucceedAsync( - stripeSession.SubscriptionId, - stripeSession.Invoice.HostedInvoiceUrl, - stripeSession.Invoice.Total, - stripeSession.Invoice.Currency, - ct) - .ConfigureAwait(false); - break; - } - - break; - case "invoice.payment_succeeded": - var invoice = stripeEvent.Data.Object as Invoice; - Debug.Assert(invoice != null); - Debug.Assert(invoice.Subscription != null); - await membershipNotifier - .NotifyPaymentSucceedAsync( - invoice.SubscriptionId, - invoice.HostedInvoiceUrl, - invoice.Total, - invoice.Currency, - ct) - .ConfigureAwait(false); - break; - - case "customer.subscription.updated": - Debug.Assert(stripeSubscription != null); - await membershipNotifier - .NotifySubscriptionUpdatedAsync( - stripeSubscription.Id, - stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt, - ct) - .ConfigureAwait(false); - break; - case "customer.subscription.deleted": - Debug.Assert(stripeSubscription != null); - await membershipNotifier - .NotifySubscriptionDeletedAsync( - stripeSubscription.Id, - ct) - .ConfigureAwait(false); - break; - } - - await SendOkAsync(ct).ConfigureAwait(false); - } -} diff --git a/backend/Modules/Memberships/Handlers/SubscribeToCreator.cs b/backend/Modules/Memberships/Handlers/SubscribeToCreator.cs deleted file mode 100644 index 4f17458..0000000 --- a/backend/Modules/Memberships/Handlers/SubscribeToCreator.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Handlers; - -[PublicAPI] -public class SubscribeRequest -{ - public Guid CreatorId { get; set; } - public Guid MembershipTierId { get; set; } - public required string CheckoutSuccessUrl { get; init; } - public required string CheckoutCancelledUrl { get; init; } -} - -[PublicAPI] -public record struct SubscriptionResponse( - string StripeCheckoutUrl); - -[PublicAPI] -public class SubscribeValidator : Validator -{ - public SubscribeValidator() - { - RuleFor(x => x.MembershipTierId).NotEmpty(); - } -} - -[PublicAPI] -public class SubscribeHandler( - MembershipsDbContext dbContext, - ICreatorLookup creatorLookup, - IMembershipPaymentProcessor membershipPaymentProcessor) - : Endpoint -{ - public override void Configure() - { - Post("/api/memberships/subscribe"); - Options(o => o.WithTags("Memberships")); - } - - public override async Task HandleAsync( - SubscribeRequest req, - CancellationToken ct) - { - MembershipTier? tier = await dbContext - .MembershipTiers - .Where(tier => tier.Id == req.MembershipTierId) - .FirstOrDefaultAsync(ct); - if (tier == null) - { - await SendNotFoundAsync(ct); - return; - } - - CreatorReference? creator = await creatorLookup.GetCreatorAsync(tier.CreatorId, ct); - if (creator == null) - { - await SendNotFoundAsync(ct); - return; - } - - if (!creator.AcceptCharges) - { - await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); - return; - } - - // Process Stripe subscription - MembershipCheckoutSession checkoutSession = await membershipPaymentProcessor.CreateCheckoutSessionAsync( - User.GetUserId(), - creator, - tier.Id, - tier.StripePriceId, - req.CheckoutSuccessUrl, - req.CheckoutCancelledUrl); - - await SendOkAsync( - new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url }, - ct); - } -} diff --git a/backend/Modules/Memberships/Migrations/20250609212641_Initial.Designer.cs b/backend/Modules/Memberships/Migrations/20250609212641_Initial.Designer.cs deleted file mode 100644 index 5f1dd0e..0000000 --- a/backend/Modules/Memberships/Migrations/20250609212641_Initial.Designer.cs +++ /dev/null @@ -1,190 +0,0 @@ -// -using System; -using Hutopy.Modules.Memberships.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Memberships.Migrations -{ - [DbContext(typeof(MembershipsDbContext))] - [Migration("20250609212641_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Memberships") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("MembershipTierId") - .HasColumnType("uuid"); - - b.Property("StartDate") - .HasColumnType("timestamp with time zone"); - - b.Property("State") - .HasColumnType("integer"); - - b.Property("StripeSubscriptionId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("TierId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("MembershipTierId"); - - b.ToTable("Memberships", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("CurrencyCode") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("character varying(4096)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("Price") - .HasColumnType("numeric"); - - b.Property("StripePriceId") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("StripeProductId") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.HasKey("Id"); - - b.ToTable("MembershipTiers", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasColumnType("numeric"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Currency") - .IsRequired() - .HasMaxLength(8) - .HasColumnType("character varying(8)"); - - b.Property("InvoiceUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("MembershipId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("MembershipId"); - - b.ToTable("Payments", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier") - .WithMany("Subscriptions") - .HasForeignKey("MembershipTierId"); - - b.Navigation("MembershipTier"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b => - { - b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null) - .WithMany("Payments") - .HasForeignKey("MembershipId"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.Navigation("Payments"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b => - { - b.Navigation("Subscriptions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Memberships/Migrations/20250609212641_Initial.cs b/backend/Modules/Memberships/Migrations/20250609212641_Initial.cs deleted file mode 100644 index 7c5597a..0000000 --- a/backend/Modules/Memberships/Migrations/20250609212641_Initial.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Memberships.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Memberships"); - - migrationBuilder.CreateTable( - name: "MembershipTiers", - schema: "Memberships", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatorId = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - Description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), - Price = table.Column(type: "numeric", nullable: false), - CurrencyCode = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - StripeProductId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - StripePriceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_MembershipTiers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Memberships", - schema: "Memberships", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - UserId = table.Column(type: "uuid", nullable: false), - CreatorId = table.Column(type: "uuid", nullable: false), - TierId = table.Column(type: "uuid", nullable: false), - MembershipTierId = table.Column(type: "uuid", nullable: true), - State = table.Column(type: "integer", nullable: false), - StartDate = table.Column(type: "timestamp with time zone", nullable: true), - EndDate = table.Column(type: "timestamp with time zone", nullable: true), - StripeSubscriptionId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Memberships", x => x.Id); - table.ForeignKey( - name: "FK_Memberships_MembershipTiers_MembershipTierId", - column: x => x.MembershipTierId, - principalSchema: "Memberships", - principalTable: "MembershipTiers", - principalColumn: "Id"); - }); - - migrationBuilder.CreateTable( - name: "Payments", - schema: "Memberships", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - Amount = table.Column(type: "numeric", nullable: false), - Currency = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), - InvoiceUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), - MembershipId = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Payments", x => x.Id); - table.ForeignKey( - name: "FK_Payments_Memberships_MembershipId", - column: x => x.MembershipId, - principalSchema: "Memberships", - principalTable: "Memberships", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_Memberships_MembershipTierId", - schema: "Memberships", - table: "Memberships", - column: "MembershipTierId"); - - migrationBuilder.CreateIndex( - name: "IX_Payments_MembershipId", - schema: "Memberships", - table: "Payments", - column: "MembershipId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Payments", - schema: "Memberships"); - - migrationBuilder.DropTable( - name: "Memberships", - schema: "Memberships"); - - migrationBuilder.DropTable( - name: "MembershipTiers", - schema: "Memberships"); - } - } -} diff --git a/backend/Modules/Memberships/Migrations/MembershipsDbContextModelSnapshot.cs b/backend/Modules/Memberships/Migrations/MembershipsDbContextModelSnapshot.cs deleted file mode 100644 index 5cffd45..0000000 --- a/backend/Modules/Memberships/Migrations/MembershipsDbContextModelSnapshot.cs +++ /dev/null @@ -1,187 +0,0 @@ -// -using System; -using Hutopy.Modules.Memberships.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Memberships.Migrations -{ - [DbContext(typeof(MembershipsDbContext))] - partial class MembershipsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Memberships") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("EndDate") - .HasColumnType("timestamp with time zone"); - - b.Property("MembershipTierId") - .HasColumnType("uuid"); - - b.Property("StartDate") - .HasColumnType("timestamp with time zone"); - - b.Property("State") - .HasColumnType("integer"); - - b.Property("StripeSubscriptionId") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("TierId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("MembershipTierId"); - - b.ToTable("Memberships", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("CurrencyCode") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("character varying(4096)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("Price") - .HasColumnType("numeric"); - - b.Property("StripePriceId") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("StripeProductId") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.HasKey("Id"); - - b.ToTable("MembershipTiers", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasColumnType("numeric"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Currency") - .IsRequired() - .HasMaxLength(8) - .HasColumnType("character varying(8)"); - - b.Property("InvoiceUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("MembershipId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("MembershipId"); - - b.ToTable("Payments", "Memberships"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier") - .WithMany("Subscriptions") - .HasForeignKey("MembershipTierId"); - - b.Navigation("MembershipTier"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b => - { - b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null) - .WithMany("Payments") - .HasForeignKey("MembershipId"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b => - { - b.Navigation("Payments"); - }); - - modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b => - { - b.Navigation("Subscriptions"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Memberships/Services/MembershipNotifier.cs b/backend/Modules/Memberships/Services/MembershipNotifier.cs deleted file mode 100644 index 76184f7..0000000 --- a/backend/Modules/Memberships/Services/MembershipNotifier.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Hutopy.Modules.Memberships.Contracts; -using Hutopy.Modules.Memberships.Data; - -namespace Hutopy.Modules.Memberships.Services; - -public class MembershipNotifier( - MembershipsDbContext dbContext) - : IMembershipNotifier -{ - public async Task NotifyCheckoutSessionCompleted( - string stripeSessionId, - string stripeSubscriptionId, - string userId, - string creatorId, - string tierId, - CancellationToken cancellationToken = default) - { - Membership membership = new() - { - Id = Guid.CreateVersion7(), - CreatedAt = DateTimeOffset.UtcNow, - UserId = Guid.Parse(userId), - CreatorId = Guid.Parse(creatorId), - TierId = Guid.Parse(tierId), - StripeSubscriptionId = stripeSubscriptionId, - State = MembershipState.Pending, - StartDate = null, - EndDate = null - }; - - dbContext.Memberships.Add(membership); - - await dbContext.SaveChangesAsync(cancellationToken); - } - - public async Task NotifyPaymentSucceedAsync( - string stripeSubscriptionId, - string hostedInvoiceUrl, - decimal amount, - string currency, - CancellationToken cancellationToken = default) - { - Membership? membership = await dbContext - .Memberships - .SingleOrDefaultAsync( - m => m.StripeSubscriptionId == stripeSubscriptionId, - cancellationToken); - if (membership is null) - { - return; - } - - Payment payment = new() - { - Id = Guid.CreateVersion7(), - CreatedAt = DateTimeOffset.UtcNow, - Amount = amount, - Currency = currency, - InvoiceUrl = hostedInvoiceUrl - }; - - membership.State = MembershipState.Active; - membership.StartDate = DateTimeOffset.UtcNow; - membership.Payments.Add(payment); - - dbContext.Payments.Add(payment); - - await dbContext.SaveChangesAsync(cancellationToken); - } - - public async Task NotifySubscriptionUpdatedAsync( - string subscriptionId, - DateTimeOffset? endDate, - CancellationToken cancellationToken = default) - { - Membership? membership = await dbContext - .Memberships - .SingleOrDefaultAsync( - s => s.StripeSubscriptionId == subscriptionId, - cancellationToken); - if (membership == null) - { - return; - } - - membership.EndDate = endDate; - - await dbContext.SaveChangesAsync(cancellationToken); - } - - public async Task NotifySubscriptionDeletedAsync( - string subscriptionId, - CancellationToken cancellationToken) - { - Membership? membership = await dbContext - .Memberships - .SingleOrDefaultAsync( - s => s.StripeSubscriptionId == subscriptionId, - cancellationToken); - if (membership == null) - { - return; - } - - membership.State = MembershipState.Inactive; - - await dbContext.SaveChangesAsync(cancellationToken); - } -} diff --git a/backend/Modules/Messaging/Data/Message.cs b/backend/Modules/Messaging/Data/Message.cs deleted file mode 100644 index 508b6ca..0000000 --- a/backend/Modules/Messaging/Data/Message.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Hutopy.Common.Domain; - -namespace Hutopy.Modules.Messaging.Data; - -public class Message : Entity -{ - public Guid SubjectId { get; set; } - public Guid? ParentId { get; set; } - [MaxLength(2048)] public required string Value { get; set; } -} diff --git a/backend/Modules/Messaging/Data/MessagingDbContext.cs b/backend/Modules/Messaging/Data/MessagingDbContext.cs deleted file mode 100644 index 1381b91..0000000 --- a/backend/Modules/Messaging/Data/MessagingDbContext.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Hutopy.Modules.Identity.Contracts; -using Hutopy.Modules.Messaging.Models; - -namespace Hutopy.Modules.Messaging.Data; - -public class MessagingDbContext( - IUserLookup userLookup, - DbContextOptions options) - : DbContext(options) -{ - public const string SchemaName = "Messaging"; - - public DbSet Messages { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - } - - public async Task> GetMessagesAsync( - Guid subjectId, - Guid? parentId, - Guid? lastId, - int pageSize, - CancellationToken ct = default) - { - IQueryable query = Messages - .Where(c => c.SubjectId == subjectId) - .Where(c => c.ParentId == parentId); - - if (lastId.HasValue) - { - var lastMessage = await Messages - .Where(c => c.Id == lastId.Value) - .Select(c => new { c.CreatedAt, c.Id }) - .FirstOrDefaultAsync(ct); - - if (lastMessage != null) - { - query = query - .Where(c => c.CreatedAt < lastMessage.CreatedAt - || (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id)); - } - } - - List messages = await query - .OrderByDescending(c => c.CreatedAt) - .ThenByDescending(c => c.Id) - .Take(pageSize) - .ToListAsync(ct); - - - MessageDto[] result = await Task.WhenAll( - messages.Select(async message => - { - UserReference? writer = await userLookup.GetUserAsync(message.CreatedBy, ct); - return new MessageDto( - message.Id, - message.SubjectId, - message.CreatedBy, - writer?.Fullname ?? "Unknown User", - writer?.PortraitUrl, - message.CreatedAt, - message.ParentId, - message.Value); - })); - - return result; - } - - public async Task GetMessageCountAsync( - Guid subjectId, - Guid? parentId, - int pageSize, - CancellationToken ct = default) - { - IQueryable query = Messages - .Where(c => c.SubjectId == subjectId) - .Where(c => c.ParentId == parentId); - - int messageCount = await query - .Take(pageSize) - .CountAsync(ct); - - return messageCount; - } -} diff --git a/backend/Modules/Messaging/DependencyInjection.cs b/backend/Modules/Messaging/DependencyInjection.cs deleted file mode 100644 index 6c76563..0000000 --- a/backend/Modules/Messaging/DependencyInjection.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddMessagingModule( - this WebApplicationBuilder builder, - Action? configureAction = null) - { - builder.Services.AddDbContext(configureAction); - - return builder; - } - - public static async Task UseMessagingModuleAsync( - this IApplicationBuilder app, - CancellationToken cancellationToken = default) - { - IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); - using IServiceScope scope = scopeFactory.CreateScope(); - await using MessagingDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - - return app; - } -} diff --git a/backend/Modules/Messaging/Handlers/AddMessage.cs b/backend/Modules/Messaging/Handlers/AddMessage.cs deleted file mode 100644 index 307cb41..0000000 --- a/backend/Modules/Messaging/Handlers/AddMessage.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging.Handlers; - -[PublicAPI] -public sealed class AddMessageRequest -{ - public Guid? Id { get; set; } - public required Guid SubjectId { get; set; } - public required string Message { get; set; } -} - -internal sealed class AddMessageRequestValidator - : Validator -{ - public AddMessageRequestValidator() - { - RuleFor(r => r.SubjectId) - .NotNull().WithMessage("You must specify a SubjectId") - .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); - - RuleFor(r => r.Message) - .NotNull().WithMessage("You must specify a Message") - .NotEmpty().WithMessage("You must specify a non-empty Message"); - } -} - -public class AddMessage( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/messages"); - Options(o => o.WithTags("Messages")); - } - - public override async Task HandleAsync( - AddMessageRequest req, - CancellationToken ct) - { - Message message = new() - { - Id = req.Id ?? Guid.CreateVersion7(), - SubjectId = req.SubjectId, - CreatedBy = User.GetUserId(), - Value = req.Message - }; - - await context.Messages.AddAsync(message, ct); - - await context.SaveChangesAsync(ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/AddReply.cs b/backend/Modules/Messaging/Handlers/AddReply.cs deleted file mode 100644 index f5fb4ee..0000000 --- a/backend/Modules/Messaging/Handlers/AddReply.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging.Handlers; - -[PublicAPI] -public sealed class AddReplyRequest -{ - public Guid? Id { get; set; } - public required Guid ParentId { get; set; } - public required Guid SubjectId { get; set; } - public required string Message { get; set; } -} - -internal sealed class AddReplyRequestValidator - : Validator -{ - public AddReplyRequestValidator() - { - RuleFor(r => r.ParentId) - .NotNull().WithMessage("You must specify a ParentId") - .NotEmpty().WithMessage("You must specify a non-empty ParentId"); - - RuleFor(r => r.SubjectId) - .NotNull().WithMessage("You must specify a SubjectId") - .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); - - RuleFor(r => r.Message) - .NotNull().WithMessage("You must specify a Message") - .NotEmpty().WithMessage("You must specify a non-empty Message"); - } -} - -internal sealed class AddReply( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/messages/{ParentId:guid}/replies"); - Options(o => o.WithTags("Messages")); - } - - public override async Task HandleAsync( - AddReplyRequest req, - CancellationToken ct) - { - Message message = new() - { - Id = Guid.CreateVersion7(), - SubjectId = req.SubjectId, - ParentId = req.ParentId, - CreatedBy = User.GetUserId(), - Value = req.Message - }; - - await context.Messages.AddAsync(message, ct); - - await context.SaveChangesAsync(ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/ChangeMessage.cs b/backend/Modules/Messaging/Handlers/ChangeMessage.cs deleted file mode 100644 index 2edc357..0000000 --- a/backend/Modules/Messaging/Handlers/ChangeMessage.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging.Handlers; - -public sealed class ChangeMessageRequest -{ - public Guid? Id { get; set; } - public required Guid SubjectId { get; set; } - public required string Message { get; set; } -} - -internal sealed class ChangeMessageRequestValidator - : Validator -{ - public ChangeMessageRequestValidator() - { - RuleFor(r => r.SubjectId) - .NotNull().WithMessage("You must specify a SubjectId") - .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); - - RuleFor(r => r.Message) - .NotNull().WithMessage("You must specify a Message") - .NotEmpty().WithMessage("You must specify a non-empty Message"); - } -} - -public class ChangeMessage( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Post("/api/messages/update"); - Options(o => o.WithTags("Messages")); - } - - public override async Task HandleAsync( - ChangeMessageRequest req, - CancellationToken ct) - { - Message? message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.Id, ct); - - if (message is null) - { - await SendNotFoundAsync(ct); - return; - } - - Guid userId = HttpContext.User.GetUserId(); - if (message.CreatedBy != userId) - { - await SendForbiddenAsync(ct); - return; - } - - message.SubjectId = req.SubjectId; - message.Value = req.Message; - - context.Update(message); - - await context.SaveChangesAsync(ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/DeleteMessage.cs b/backend/Modules/Messaging/Handlers/DeleteMessage.cs deleted file mode 100644 index 8bbeb9b..0000000 --- a/backend/Modules/Messaging/Handlers/DeleteMessage.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging.Handlers; - -public record DeleteMessageRequest(Guid MessageId); - -internal sealed class DeleteMessageRequestValidator - : Validator -{ - public DeleteMessageRequestValidator() - { - RuleFor(r => r.MessageId) - .NotNull().WithMessage("You must specify a MessageId") - .NotEmpty().WithMessage("You must specify a non-empty MessageId"); - } -} - -public class DeleteMessage( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Delete("/api/messages/{MessageId}"); - Options(o => o.WithTags("Messages")); - } - - public override async Task HandleAsync( - DeleteMessageRequest req, - CancellationToken ct) - { - Message? message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.MessageId, ct); - - if (message is null) - { - await SendNotFoundAsync(ct); - return; - } - - Guid userId = HttpContext.User.GetUserId(); - if (message.CreatedBy != userId) - { - await SendForbiddenAsync(ct); - return; - } - - context.Messages.Remove(message); - - await context.SaveChangesAsync(ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/GetMessageCount.cs b/backend/Modules/Messaging/Handlers/GetMessageCount.cs deleted file mode 100644 index d13d7d4..0000000 --- a/backend/Modules/Messaging/Handlers/GetMessageCount.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Hutopy.Modules.Messaging.Data; - -namespace Hutopy.Modules.Messaging.Handlers; - -public sealed class GetMessageCountRequest -{ - public Guid SubjectId { get; set; } - [BindFrom("page_size")] public int PageSize { get; set; } = 1000; -} - -public record struct GetMessageCountResponse -{ - public required int Count { get; init; } -} - -public class GetMessageCount( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/messages/{SubjectId:guid}/count"); - Options(o => o.WithTags("Messages")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetMessageCountRequest req, - CancellationToken ct) - { - int messageCount = await context.GetMessageCountAsync( - req.SubjectId, - null, - req.PageSize, - ct); - - await SendAsync( - new GetMessageCountResponse { Count = messageCount }, - cancellation: ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/GetMessages.cs b/backend/Modules/Messaging/Handlers/GetMessages.cs deleted file mode 100644 index 63a7ae4..0000000 --- a/backend/Modules/Messaging/Handlers/GetMessages.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Hutopy.Modules.Messaging.Data; -using Hutopy.Modules.Messaging.Models; - -namespace Hutopy.Modules.Messaging.Handlers; - -[PublicAPI] -public sealed class GetMessagesRequest -{ - public Guid SubjectId { get; set; } - [BindFrom("page_size")] public int PageSize { get; set; } = 10; - [BindFrom("last_id")] public Guid? LastId { get; set; } -} - -[PublicAPI] -public record struct GetMessagesResponse( - IEnumerable Messages); - -public class GetMessages( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/messages/{SubjectId:guid}"); - Options(o => o.WithTags("Messages")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetMessagesRequest req, - CancellationToken ct) - { - IEnumerable messages = await context.GetMessagesAsync( - req.SubjectId, - null, - req.LastId, - req.PageSize, - ct); - - await SendOkAsync(new GetMessagesResponse(messages), ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/GetMessagesByUser.cs b/backend/Modules/Messaging/Handlers/GetMessagesByUser.cs deleted file mode 100644 index 9d9db35..0000000 --- a/backend/Modules/Messaging/Handlers/GetMessagesByUser.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Hutopy.Modules.Identity.Contracts; -using Hutopy.Modules.Messaging.Data; -using Hutopy.Modules.Messaging.Models; - -namespace Hutopy.Modules.Messaging.Handlers; - -[PublicAPI] -public class GetMessagesByUserRequest -{ - public Guid UserId { get; set; } -} - -[PublicAPI] -public record struct GetMessagesByUserResponse( - IEnumerable Messages); - -public class GetMessagesByUser( - IUserLookup userLookup, - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/messages/user/{UserId:guid}"); - Options(o => o.WithTags("Messages")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetMessagesByUserRequest req, - CancellationToken ct) - { - List messages = await context - .Messages - .Where(c => c.CreatedBy == req.UserId) - .Where(c => c.ParentId == null) - .ToListAsync(ct); - - MessageDto[] result = await Task.WhenAll( - messages.Select(async message => - { - UserReference? user = await userLookup.GetUserAsync(message.CreatedBy, ct); - - return new MessageDto - { - Id = message.Id, - ParentId = message.ParentId, - CreatedAt = message.CreatedAt, - CreatedBy = message.CreatedBy, - CreatedByName = user?.Fullname ?? "Unknown User", - CreatedByPortraitUrl = user?.PortraitUrl ?? "", - SubjectId = message.SubjectId, - Value = message.Value - }; - })); - - await SendOkAsync(new GetMessagesByUserResponse(result), ct); - } -} diff --git a/backend/Modules/Messaging/Handlers/GetReplies.cs b/backend/Modules/Messaging/Handlers/GetReplies.cs deleted file mode 100644 index 52546db..0000000 --- a/backend/Modules/Messaging/Handlers/GetReplies.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Hutopy.Modules.Messaging.Data; -using Hutopy.Modules.Messaging.Models; - -namespace Hutopy.Modules.Messaging.Handlers; - -[PublicAPI] -public class GetRepliesRequest -{ - public Guid SubjectId { get; set; } - public Guid ParentId { get; set; } - [BindFrom("page_size")] public int PageSize { get; set; } = 10; - [BindFrom("last_id")] public Guid? LastId { get; set; } -} - -[PublicAPI] -public record struct GetRepliesResponse( - IEnumerable Messages); - -public class GetReplies( - MessagingDbContext context) - : Endpoint -{ - public override void Configure() - { - Get("/api/messages/{ParentId:guid}/replies"); - Options(o => o.WithTags("Messages")); - AllowAnonymous(); - } - - public override async Task HandleAsync( - GetRepliesRequest req, - CancellationToken ct) - { - IEnumerable replies = await context.GetMessagesAsync( - req.SubjectId, - req.ParentId, - req.LastId, - req.PageSize, - ct); - - await SendOkAsync(new GetRepliesResponse(replies), ct); - } -} diff --git a/backend/Modules/Messaging/Migrations/20250609171331_Initial.Designer.cs b/backend/Modules/Messaging/Migrations/20250609171331_Initial.Designer.cs deleted file mode 100644 index 392b0fe..0000000 --- a/backend/Modules/Messaging/Migrations/20250609171331_Initial.Designer.cs +++ /dev/null @@ -1,67 +0,0 @@ -// -using System; -using Hutopy.Modules.Messaging.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Messaging.Migrations -{ - [DbContext(typeof(MessagingDbContext))] - [Migration("20250609171331_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Messaging") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Messaging.Data.Message", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ParentId") - .HasColumnType("uuid"); - - b.Property("SubjectId") - .HasColumnType("uuid"); - - b.Property("Value") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.HasKey("Id"); - - b.ToTable("Messages", "Messaging"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Messaging/Migrations/20250609171331_Initial.cs b/backend/Modules/Messaging/Migrations/20250609171331_Initial.cs deleted file mode 100644 index bbf8023..0000000 --- a/backend/Modules/Messaging/Migrations/20250609171331_Initial.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Messaging.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Messaging"); - - migrationBuilder.CreateTable( - name: "Messages", - schema: "Messaging", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - SubjectId = table.Column(type: "uuid", nullable: false), - ParentId = table.Column(type: "uuid", nullable: true), - Value = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Messages", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Messages", - schema: "Messaging"); - } - } -} diff --git a/backend/Modules/Messaging/Migrations/MessagingDbContextModelSnapshot.cs b/backend/Modules/Messaging/Migrations/MessagingDbContextModelSnapshot.cs deleted file mode 100644 index a93407b..0000000 --- a/backend/Modules/Messaging/Migrations/MessagingDbContextModelSnapshot.cs +++ /dev/null @@ -1,64 +0,0 @@ -// -using System; -using Hutopy.Modules.Messaging.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Messaging.Migrations -{ - [DbContext(typeof(MessagingDbContext))] - partial class MessagingDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Messaging") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Messaging.Data.Message", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ParentId") - .HasColumnType("uuid"); - - b.Property("SubjectId") - .HasColumnType("uuid"); - - b.Property("Value") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.HasKey("Id"); - - b.ToTable("Messages", "Messaging"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Messaging/Models/MessageDto.cs b/backend/Modules/Messaging/Models/MessageDto.cs deleted file mode 100644 index c4edadf..0000000 --- a/backend/Modules/Messaging/Models/MessageDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Hutopy.Modules.Messaging.Models; - -public record struct MessageDto( - Guid Id, - Guid SubjectId, - Guid CreatedBy, - string CreatedByName, - string? CreatedByPortraitUrl, - DateTimeOffset CreatedAt, - Guid? ParentId, - string Value -); diff --git a/backend/Modules/Notifications/Contracts/INotificationEventWriter.cs b/backend/Modules/Notifications/Contracts/INotificationEventWriter.cs new file mode 100644 index 0000000..e0545cb --- /dev/null +++ b/backend/Modules/Notifications/Contracts/INotificationEventWriter.cs @@ -0,0 +1,17 @@ +namespace Socialize.Modules.Notifications.Contracts; + +public record NotificationEventWriteModel( + Guid WorkspaceId, + Guid? ContentItemId, + string EventType, + string EntityType, + Guid EntityId, + string Message, + Guid? RecipientUserId, + string? RecipientEmail, + string? MetadataJson); + +public interface INotificationEventWriter +{ + Task WriteAsync(NotificationEventWriteModel model, CancellationToken cancellationToken = default); +} diff --git a/backend/Modules/Notifications/Data/NotificationEvent.cs b/backend/Modules/Notifications/Data/NotificationEvent.cs new file mode 100644 index 0000000..03fb1a2 --- /dev/null +++ b/backend/Modules/Notifications/Data/NotificationEvent.cs @@ -0,0 +1,17 @@ +namespace Socialize.Modules.Notifications.Data; + +public class NotificationEvent +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid? ContentItemId { get; set; } + public required string EventType { get; set; } + public required string EntityType { get; set; } + public Guid EntityId { get; set; } + public required string Message { get; set; } + public Guid? RecipientUserId { get; set; } + public string? RecipientEmail { get; set; } + public string? MetadataJson { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? ReadAt { get; set; } +} diff --git a/backend/Modules/Notifications/DependencyInjection.cs b/backend/Modules/Notifications/DependencyInjection.cs new file mode 100644 index 0000000..640b39b --- /dev/null +++ b/backend/Modules/Notifications/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Socialize.Modules.Notifications.Contracts; +using Socialize.Modules.Notifications.Data; +using Socialize.Modules.Notifications.Services; + +namespace Socialize.Modules.Notifications; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddNotificationsModule( + this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/backend/Modules/Notifications/Handlers/GetNotifications.cs b/backend/Modules/Notifications/Handlers/GetNotifications.cs new file mode 100644 index 0000000..54e617b --- /dev/null +++ b/backend/Modules/Notifications/Handlers/GetNotifications.cs @@ -0,0 +1,88 @@ +using Socialize.Infrastructure.Security; +namespace Socialize.Modules.Notifications.Handlers; + +public record GetNotificationsRequest(Guid? WorkspaceId, Guid? ContentItemId); + +public record NotificationEventDto( + Guid Id, + Guid WorkspaceId, + Guid? ContentItemId, + string EventType, + string EntityType, + Guid EntityId, + string Message, + Guid? RecipientUserId, + string? RecipientEmail, + string? MetadataJson, + DateTimeOffset CreatedAt, + DateTimeOffset? ReadAt); + +public class GetNotificationsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/notifications"); + Options(o => o.WithTags("Notifications")); + } + + public override async Task HandleAsync(GetNotificationsRequest request, CancellationToken ct) + { + if (request.ContentItemId.HasValue) + { + ContentItem? item = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct); + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + { + await SendForbiddenAsync(ct); + return; + } + } + + IQueryable query = dbContext.NotificationEvents.AsQueryable(); + + if (!accessScopeService.IsManager(User)) + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId)); + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value); + } + + if (request.ContentItemId.HasValue) + { + query = query.Where(notificationEvent => notificationEvent.ContentItemId == request.ContentItemId.Value); + } + + List notifications = await query + .OrderByDescending(notificationEvent => notificationEvent.CreatedAt) + .Take(100) + .Select(notificationEvent => new NotificationEventDto( + notificationEvent.Id, + notificationEvent.WorkspaceId, + notificationEvent.ContentItemId, + notificationEvent.EventType, + notificationEvent.EntityType, + notificationEvent.EntityId, + notificationEvent.Message, + notificationEvent.RecipientUserId, + notificationEvent.RecipientEmail, + notificationEvent.MetadataJson, + notificationEvent.CreatedAt, + notificationEvent.ReadAt)) + .ToListAsync(ct); + + await SendOkAsync(notifications, ct); + } +} diff --git a/backend/Modules/Notifications/Handlers/MarkNotificationAsRead.cs b/backend/Modules/Notifications/Handlers/MarkNotificationAsRead.cs new file mode 100644 index 0000000..ada7745 --- /dev/null +++ b/backend/Modules/Notifications/Handlers/MarkNotificationAsRead.cs @@ -0,0 +1,39 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Notifications.Data; + +namespace Socialize.Modules.Notifications.Handlers; + +public class MarkNotificationAsReadHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/notifications/{id}/read"); + Options(o => o.WithTags("Notifications")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + + NotificationEvent? notificationEvent = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + if (notificationEvent is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + notificationEvent.ReadAt = notificationEvent.ReadAt ?? DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(ct); + + await SendNoContentAsync(ct); + } +} diff --git a/backend/Modules/Notifications/Services/NotificationEventWriter.cs b/backend/Modules/Notifications/Services/NotificationEventWriter.cs new file mode 100644 index 0000000..27ebbdf --- /dev/null +++ b/backend/Modules/Notifications/Services/NotificationEventWriter.cs @@ -0,0 +1,30 @@ +using Socialize.Modules.Notifications.Contracts; +using Socialize.Modules.Notifications.Data; + +namespace Socialize.Modules.Notifications.Services; + +public class NotificationEventWriter( + AppDbContext dbContext) + : INotificationEventWriter +{ + public async Task WriteAsync(NotificationEventWriteModel model, CancellationToken cancellationToken = default) + { + NotificationEvent notificationEvent = new() + { + Id = Guid.NewGuid(), + WorkspaceId = model.WorkspaceId, + ContentItemId = model.ContentItemId, + EventType = model.EventType, + EntityType = model.EntityType, + EntityId = model.EntityId, + Message = model.Message, + RecipientUserId = model.RecipientUserId, + RecipientEmail = model.RecipientEmail, + MetadataJson = model.MetadataJson, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.NotificationEvents.Add(notificationEvent); + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/Modules/Projects/Data/Project.cs b/backend/Modules/Projects/Data/Project.cs new file mode 100644 index 0000000..0bfded0 --- /dev/null +++ b/backend/Modules/Projects/Data/Project.cs @@ -0,0 +1,15 @@ +namespace Socialize.Modules.Projects.Data; + +public class Project +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ClientId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + public required string Status { get; set; } + public DateTimeOffset StartDate { get; set; } + public DateTimeOffset EndDate { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Projects/DependencyInjection.cs b/backend/Modules/Projects/DependencyInjection.cs new file mode 100644 index 0000000..1991718 --- /dev/null +++ b/backend/Modules/Projects/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Modules.Projects.Data; + +namespace Socialize.Modules.Projects; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddProjectsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/Modules/Projects/Handlers/CreateProject.cs b/backend/Modules/Projects/Handlers/CreateProject.cs new file mode 100644 index 0000000..941d8c5 --- /dev/null +++ b/backend/Modules/Projects/Handlers/CreateProject.cs @@ -0,0 +1,115 @@ +using Socialize.Infrastructure.Security; +namespace Socialize.Modules.Projects.Handlers; + +public record CreateProjectRequest( + Guid WorkspaceId, + Guid ClientId, + string Name, + DateTimeOffset StartDate, + DateTimeOffset EndDate, + string? Description, + string? Notes); + +public class CreateProjectRequestValidator + : Validator +{ + public CreateProjectRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.ClientId).NotEmpty(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.StartDate).NotEmpty(); + RuleFor(x => x.EndDate) + .NotEmpty() + .GreaterThanOrEqualTo(x => x.StartDate); + RuleFor(x => x.Description).MaximumLength(4000); + RuleFor(x => x.Notes).MaximumLength(4000); + } +} + +public class CreateProjectHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/projects"); + Options(o => o.WithTags("Projects")); + } + + public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct) + { + if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + bool workspaceExists = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct); + + if (!workspaceExists) + { + AddError(request => request.WorkspaceId, "The selected workspace does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + bool clientExists = await dbContext.Clients + .AnyAsync( + client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId, + ct); + + if (!clientExists) + { + AddError(request => request.ClientId, "The selected client does not belong to the active workspace."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + string normalizedName = request.Name.Trim(); + + bool duplicateProject = await dbContext.Projects + .AnyAsync( + project => project.ClientId == request.ClientId && project.Name == normalizedName, + ct); + + if (duplicateProject) + { + AddError(request => request.Name, "A project with this name already exists for the selected client."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + Project project = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + ClientId = request.ClientId, + Name = normalizedName, + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + Status = "Planned", + StartDate = request.StartDate, + EndDate = request.EndDate, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Projects.Add(project); + await dbContext.SaveChangesAsync(ct); + + ProjectDto dto = new( + project.Id, + project.WorkspaceId, + project.ClientId, + project.Name, + project.Description, + project.Notes, + project.Status, + project.StartDate, + project.EndDate); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Projects/Handlers/GetProjects.cs b/backend/Modules/Projects/Handlers/GetProjects.cs new file mode 100644 index 0000000..82f88e2 --- /dev/null +++ b/backend/Modules/Projects/Handlers/GetProjects.cs @@ -0,0 +1,86 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Projects.Data; + +namespace Socialize.Modules.Projects.Handlers; + +public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId); + +public record ProjectDto( + Guid Id, + Guid WorkspaceId, + Guid ClientId, + string Name, + string? Description, + string? Notes, + string Status, + DateTimeOffset StartDate, + DateTimeOffset EndDate); + +public class GetProjectsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/projects"); + Options(o => o.WithTags("Projects")); + } + + public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct) + { + IQueryable query = dbContext.Projects.AsQueryable(); + + if (accessScopeService.IsManager(User)) + { + if (request.WorkspaceId.HasValue) + { + query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value); + } + } + else + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); + IReadOnlyCollection projectScopeIds = User.GetProjectScopeIds(); + + query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId)); + + if (clientScopeIds.Count > 0) + { + query = query.Where(project => clientScopeIds.Contains(project.ClientId)); + } + + if (projectScopeIds.Count > 0) + { + query = query.Where(project => projectScopeIds.Contains(project.Id)); + } + } + + if (request.ClientId.HasValue) + { + query = query.Where(project => project.ClientId == request.ClientId.Value); + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value); + } + + List projects = await query + .OrderBy(project => project.Name) + .Select(project => new ProjectDto( + project.Id, + project.WorkspaceId, + project.ClientId, + project.Name, + project.Description, + project.Notes, + project.Status, + project.StartDate, + project.EndDate)) + .ToListAsync(ct); + + await SendOkAsync(projects, ct); + } +} diff --git a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs b/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs deleted file mode 100644 index 85848ce..0000000 --- a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Hutopy.Modules.Tipping.Contracts; - -internal interface ITipPaymentNotifier -{ - Task NotifyPaymentSucceedAsync( - string sessionId, - Uri receiptUrl, - string customerEmail, - CancellationToken ct); -} diff --git a/backend/Modules/Tipping/Contracts/ITipProcessor.cs b/backend/Modules/Tipping/Contracts/ITipProcessor.cs deleted file mode 100644 index a6a563e..0000000 --- a/backend/Modules/Tipping/Contracts/ITipProcessor.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Hutopy.Modules.Creators.Contracts; - -namespace Hutopy.Modules.Tipping.Contracts; - -internal interface ITipProcessor -{ - Task CreateCheckoutSessionAsync( - Guid tipId, - CreatorReference creator, - decimal amount, - string currency, - string message, - Uri successUrl, - Uri cancelUrl, - CancellationToken ct = default); -} diff --git a/backend/Modules/Tipping/Contracts/TipCheckoutSession.cs b/backend/Modules/Tipping/Contracts/TipCheckoutSession.cs deleted file mode 100644 index 23833fe..0000000 --- a/backend/Modules/Tipping/Contracts/TipCheckoutSession.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Hutopy.Modules.Tipping.Contracts; - -public record TipCheckoutSession( - string Id, - string Url); diff --git a/backend/Modules/Tipping/Data/Tip.cs b/backend/Modules/Tipping/Data/Tip.cs deleted file mode 100644 index 432a143..0000000 --- a/backend/Modules/Tipping/Data/Tip.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Hutopy.Common.Domain; - -namespace Hutopy.Modules.Tipping.Data; - -public class Tip : Entity -{ - public Guid CreatorId { get; set; } - public TipStatus Status { get; set; } - public decimal Amount { get; set; } - [MaxLength(8)] public required string Currency { get; set; } - [MaxLength(2048)] public required string Message { get; set; } - [MaxLength(256)] public required string StripeSessionId { get; set; } - [MaxLength(2048)] public string? StripeInvoiceUrl { get; set; } -} - -public enum TipStatus : short -{ - Pending = 0, - Paid = 1 -} diff --git a/backend/Modules/Tipping/Data/TippingDbContext.cs b/backend/Modules/Tipping/Data/TippingDbContext.cs deleted file mode 100644 index 7501b28..0000000 --- a/backend/Modules/Tipping/Data/TippingDbContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Hutopy.Modules.Tipping.Data; - -public sealed class TippingDbContext( - DbContextOptions options) - : DbContext(options) -{ - public const string SchemaName = "Tipping"; - - public DbSet Tips => Set(); - - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder - .Entity() - .Property(c => c.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - } -} diff --git a/backend/Modules/Tipping/DependencyInjection.cs b/backend/Modules/Tipping/DependencyInjection.cs deleted file mode 100644 index a4014db..0000000 --- a/backend/Modules/Tipping/DependencyInjection.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Hutopy.Modules.Messaging.Data; -using Hutopy.Modules.Tipping.Contracts; -using Hutopy.Modules.Tipping.Data; -using Hutopy.Modules.Tipping.Services; - -namespace Hutopy.Modules.Tipping; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddTippingModule( - this WebApplicationBuilder builder, - Action? configureAction = null) - { - builder.Services.AddDbContext(configureAction); - builder.Services.AddTransient(); - - return builder; - } - - public static async Task UseTippingModuleAsync( - this IApplicationBuilder app, - CancellationToken cancellationToken = default) - { - IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); - using IServiceScope scope = scopeFactory.CreateScope(); - await using TippingDbContext context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken); - - return app; - } -} diff --git a/backend/Modules/Tipping/Handlers/GetReceivedTips.cs b/backend/Modules/Tipping/Handlers/GetReceivedTips.cs deleted file mode 100644 index e43bb17..0000000 --- a/backend/Modules/Tipping/Handlers/GetReceivedTips.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Contracts; -using Hutopy.Modules.Tipping.Data; -using Hutopy.Modules.Tipping.Models; - -namespace Hutopy.Modules.Tipping.Handlers; - -[PublicAPI] -public record struct GetReceivedTipsResponse( - IEnumerable Tips); - -[PublicAPI] -public class GetReceivedTipsHandler( - IUserLookup userLookup, - TippingDbContext dbContext) - : EndpointWithoutRequest -{ - public override void Configure() - { - Get("/api/tips"); - Options(o => o.WithTags("Tips")); - } - - public override async Task HandleAsync( - CancellationToken ct) - { - List tips = await dbContext - .Tips - .Where(tip => tip.CreatorId == User.GetUserId()) - .ToListAsync(ct); - - TipReceivedModel[] result = await Task.WhenAll( - tips.Select(async tip => - { - UserReference? tipper = await userLookup.GetUserAsync(tip.CreatorId, ct); - - return new TipReceivedModel( - tip.Id, - tip.CreatedAt, - tip.CreatedBy, - tipper?.Fullname ?? "Unknown User", - tip.Amount, - tip.Currency, - tip.Message); - })); - - await SendOkAsync(new GetReceivedTipsResponse(result), ct); - } -} diff --git a/backend/Modules/Tipping/Handlers/SendTip.cs b/backend/Modules/Tipping/Handlers/SendTip.cs deleted file mode 100644 index 280dd84..0000000 --- a/backend/Modules/Tipping/Handlers/SendTip.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Tipping.Contracts; -using Hutopy.Modules.Tipping.Data; - -namespace Hutopy.Modules.Tipping.Handlers; - -[PublicAPI] -internal static class SendTip -{ - internal record Request( - Guid CreatorId, - decimal Amount, - string Currency, - string Message, - Uri CheckoutSuccessUrl, - Uri CheckoutCancelledUrl); - - internal record Response( - string Id, - Uri Url); - - internal class Validator : Validator - { - public Validator() - { - RuleFor(x => x.Amount) - .GreaterThan(0) - .WithMessage("Tip amount must be greater than 0"); - - RuleFor(x => x.CreatorId) - .NotEmpty() - .WithMessage("Creator ID is required"); - - RuleFor(x => x.CheckoutSuccessUrl) - .NotEmpty() - .WithMessage("CheckoutSuccessUrl is required"); - - RuleFor(x => x.CheckoutCancelledUrl) - .NotEmpty() - .WithMessage("CheckoutCancelledUrl is required"); - } - } - - internal class Handler( - TippingDbContext dbContext, - ITipProcessor tipProcessor, - ICreatorLookup creatorLookup) - : Endpoint - { - private static readonly Guid AnonymousUserId = Guid.Parse("AAAAAAAA-0000-0000-0000-000000000000"); - - public override void Configure() - { - Post("/api/tips"); - Options(o => o.WithTags("Memberships")); - - AllowAnonymous(); - } - - public override async Task HandleAsync( - Request req, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(req); - - var userId = User.Identity?.IsAuthenticated == true - ? User.GetUserId() - : AnonymousUserId; - - var creator = await creatorLookup - .GetCreatorAsync(req.CreatorId, ct) - .ConfigureAwait(false); - - if (creator == null) - { - await SendNotFoundAsync(ct).ConfigureAwait(false); - return; - } - - if (!creator.AcceptCharges) - { - await SendErrorsAsync(StatusCodes.Status400BadRequest, ct).ConfigureAwait(false); - return; - } - - var tipId = Guid.CreateVersion7(); - - var checkout = await tipProcessor - .CreateCheckoutSessionAsync( - tipId, - creator, - req.Amount, - req.Currency, - req.Message, - req.CheckoutSuccessUrl, - req.CheckoutCancelledUrl, - ct) - .ConfigureAwait(false); - - Tip tip = new() - { - Id = tipId, - CreatedAt = DateTimeOffset.UtcNow, - StripeSessionId = checkout.Id, - CreatedBy = userId, - CreatorId = req.CreatorId, - Status = TipStatus.Pending, - Amount = req.Amount, - Currency = req.Currency, - Message = req.Message - }; - - dbContext.Tips.Add(tip); - - await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); - - await SendAsync( - new Response(checkout.Id, new Uri(checkout.Url)), - cancellation: ct) - .ConfigureAwait(false); - } - } -} diff --git a/backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs b/backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs deleted file mode 100644 index 6b14f36..0000000 --- a/backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs +++ /dev/null @@ -1,80 +0,0 @@ -// -using System; -using Hutopy.Modules.Tipping.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Tipping.Migrations -{ - [DbContext(typeof(TippingDbContext))] - [Migration("20250609171342_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Tipping") - .HasAnnotation("ProductVersion", "9.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Tipping.Data.Tip", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasColumnType("numeric"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("Currency") - .IsRequired() - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Message") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .HasColumnType("smallint"); - - b.Property("StripeInvoiceUrl") - .HasColumnType("text"); - - b.Property("StripeSessionId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Tips", "Tipping"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Tipping/Migrations/20250609171342_Initial.cs b/backend/Modules/Tipping/Migrations/20250609171342_Initial.cs deleted file mode 100644 index 73acf9d..0000000 --- a/backend/Modules/Tipping/Migrations/20250609171342_Initial.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Tipping.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "Tipping"); - - migrationBuilder.CreateTable( - name: "Tips", - schema: "Tipping", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatorId = table.Column(type: "uuid", nullable: false), - Status = table.Column(type: "smallint", nullable: false), - Amount = table.Column(type: "numeric", nullable: false), - Currency = table.Column(type: "text", nullable: false), - Message = table.Column(type: "text", nullable: false), - StripeSessionId = table.Column(type: "text", nullable: false), - StripeInvoiceUrl = table.Column(type: "text", nullable: true), - CreatedBy = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - DeletedBy = table.Column(type: "uuid", nullable: true), - DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Tips", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Tips", - schema: "Tipping"); - } - } -} diff --git a/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.Designer.cs b/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.Designer.cs deleted file mode 100644 index b9b3cf3..0000000 --- a/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.Designer.cs +++ /dev/null @@ -1,84 +0,0 @@ -// -using System; -using Hutopy.Modules.Tipping.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Tipping.Migrations -{ - [DbContext(typeof(TippingDbContext))] - [Migration("20250731175148_TippingIssues")] - partial class TippingIssues - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Tipping") - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Tipping.Data.Tip", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasColumnType("numeric"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("Currency") - .IsRequired() - .HasMaxLength(8) - .HasColumnType("character varying(8)"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Message") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("Status") - .HasColumnType("smallint"); - - b.Property("StripeInvoiceUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("StripeSessionId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.ToTable("Tips", "Tipping"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.cs b/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.cs deleted file mode 100644 index 1b2222b..0000000 --- a/backend/Modules/Tipping/Migrations/20250731175148_TippingIssues.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Hutopy.Modules.Tipping.Migrations -{ - /// - public partial class TippingIssues : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "StripeSessionId", - schema: "Tipping", - table: "Tips", - type: "character varying(256)", - maxLength: 256, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - - migrationBuilder.AlterColumn( - name: "StripeInvoiceUrl", - schema: "Tipping", - table: "Tips", - type: "character varying(2048)", - maxLength: 2048, - nullable: true, - oldClrType: typeof(string), - oldType: "text", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Message", - schema: "Tipping", - table: "Tips", - type: "character varying(2048)", - maxLength: 2048, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - - migrationBuilder.AlterColumn( - name: "Currency", - schema: "Tipping", - table: "Tips", - type: "character varying(8)", - maxLength: 8, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "StripeSessionId", - schema: "Tipping", - table: "Tips", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(256)", - oldMaxLength: 256); - - migrationBuilder.AlterColumn( - name: "StripeInvoiceUrl", - schema: "Tipping", - table: "Tips", - type: "text", - nullable: true, - oldClrType: typeof(string), - oldType: "character varying(2048)", - oldMaxLength: 2048, - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Message", - schema: "Tipping", - table: "Tips", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(2048)", - oldMaxLength: 2048); - - migrationBuilder.AlterColumn( - name: "Currency", - schema: "Tipping", - table: "Tips", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(8)", - oldMaxLength: 8); - } - } -} diff --git a/backend/Modules/Tipping/Migrations/TippingDbContextModelSnapshot.cs b/backend/Modules/Tipping/Migrations/TippingDbContextModelSnapshot.cs deleted file mode 100644 index a847e01..0000000 --- a/backend/Modules/Tipping/Migrations/TippingDbContextModelSnapshot.cs +++ /dev/null @@ -1,81 +0,0 @@ -// -using System; -using Hutopy.Modules.Tipping.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Hutopy.Modules.Tipping.Migrations -{ - [DbContext(typeof(TippingDbContext))] - partial class TippingDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Tipping") - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Hutopy.Modules.Tipping.Data.Tip", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasColumnType("numeric"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("CreatorId") - .HasColumnType("uuid"); - - b.Property("Currency") - .IsRequired() - .HasMaxLength(8) - .HasColumnType("character varying(8)"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Message") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("Status") - .HasColumnType("smallint"); - - b.Property("StripeInvoiceUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b.Property("StripeSessionId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.ToTable("Tips", "Tipping"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/Modules/Tipping/Models/TipReceivedModel.cs b/backend/Modules/Tipping/Models/TipReceivedModel.cs deleted file mode 100644 index 6324075..0000000 --- a/backend/Modules/Tipping/Models/TipReceivedModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Hutopy.Modules.Tipping.Models; - -[PublicAPI] -public record struct TipReceivedModel( - Guid Id, - DateTimeOffset CreatedAt, - Guid TipperId, - string TipperName, - decimal Amount, - string Currency, - string Message); diff --git a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs b/backend/Modules/Tipping/Services/TipPaymentNotifier.cs deleted file mode 100644 index 79bebb4..0000000 --- a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Hutopy.Infrastructure.Emailer.Contracts; -using Hutopy.Modules.Creators.Contracts; -using Hutopy.Modules.Tipping.Contracts; -using Hutopy.Modules.Tipping.Data; - -namespace Hutopy.Modules.Tipping.Services; - -internal class TipPaymentNotifier( - TippingDbContext dbContext, - IEmailSender emailSender, - ICreatorLookup creatorLookup, - ILogger logger) - : ITipPaymentNotifier -{ - public async Task NotifyPaymentSucceedAsync( - string sessionId, - Uri receiptUrl, - string customerEmail, - CancellationToken ct) - { - var tip = await dbContext - .Tips - .SingleOrDefaultAsync( - t => t.StripeSessionId == sessionId, - ct) - .ConfigureAwait(false); - - if (tip is not null) - { - tip.Status = TipStatus.Paid; - tip.StripeInvoiceUrl = receiptUrl.ToString(); // Store the receipt URL - - await dbContext - .SaveChangesAsync(ct) - .ConfigureAwait(false); - - // Look up creator information - var creator = await creatorLookup - .GetCreatorAsync(tip.CreatorId, ct) - .ConfigureAwait(false); - - if (!string.IsNullOrEmpty(customerEmail)) - { - await SendTipConfirmationEmailAsync( - customerEmail, - creator?.Name ?? "le créateur", - tip.Amount / 100m, - tip.Currency, - receiptUrl) - .ConfigureAwait(false); // Pass the receipt URL - } - } - else - { - logger.LogError("Tip with session ID {SessionId} not found", sessionId); - } - } - - private async Task SendTipConfirmationEmailAsync( - string email, - string creatorUsername, - decimal amount, - string currency, - Uri receiptUrl) // Add receipt URL parameter - { - var subject = $"Merci pour votre soutien à {creatorUsername}"; - var message = $""" -

-

{creatorUsername} vous remercie !

- -

- Votre paiement de {amount} {currency} a été traité avec succès. -

- -
-

- Ce reçu confirme votre soutien à {creatorUsername}. Merci de contribuer à son travail ! -

-
- - - -

- Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives. -

- -

- Merci d'utiliser Hutopy pour soutenir vos créateurs préférés ! -

-
- """; - - try - { - await emailSender - .SendEmailAsync(email, subject, message) - .ConfigureAwait(false); - - logger.LogInformation("Tip confirmation email sent to {Email} for tip to {Creator}", email, - creatorUsername); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to send tip confirmation email to {Email}", email); - // Don't throw the exception as this should not fail the payment processing - } - } -} diff --git a/backend/Modules/Workspaces/Data/Workspace.cs b/backend/Modules/Workspaces/Data/Workspace.cs new file mode 100644 index 0000000..34e6eb9 --- /dev/null +++ b/backend/Modules/Workspaces/Data/Workspace.cs @@ -0,0 +1,11 @@ +namespace Socialize.Modules.Workspaces.Data; + +public class Workspace +{ + public Guid Id { get; init; } + public required string Name { get; set; } + public required string Slug { get; set; } + public Guid OwnerUserId { get; set; } + public required string TimeZone { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Workspaces/Data/WorkspaceInvite.cs b/backend/Modules/Workspaces/Data/WorkspaceInvite.cs new file mode 100644 index 0000000..b2399e9 --- /dev/null +++ b/backend/Modules/Workspaces/Data/WorkspaceInvite.cs @@ -0,0 +1,12 @@ +namespace Socialize.Modules.Workspaces.Data; + +public class WorkspaceInvite +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public required string Email { get; set; } + public required string Role { get; set; } + public required string Status { get; set; } + public Guid InvitedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/Modules/Workspaces/DependencyInjection.cs b/backend/Modules/Workspaces/DependencyInjection.cs new file mode 100644 index 0000000..95ea013 --- /dev/null +++ b/backend/Modules/Workspaces/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Socialize.Modules.Workspaces.Data; +using Socialize.Infrastructure.Development; + +namespace Socialize.Modules.Workspaces; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddWorkspaceModule( + this WebApplicationBuilder builder) + { + builder.Services.Configure( + builder.Configuration.GetSection(DevelopmentSeedOptions.SectionName)); + + return builder; + } +} diff --git a/backend/Modules/Workspaces/Handlers/CreateWorkspace.cs b/backend/Modules/Workspaces/Handlers/CreateWorkspace.cs new file mode 100644 index 0000000..c980531 --- /dev/null +++ b/backend/Modules/Workspaces/Handlers/CreateWorkspace.cs @@ -0,0 +1,80 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Workspaces.Data; + +namespace Socialize.Modules.Workspaces.Handlers; + +public record CreateWorkspaceRequest( + string Name, + string Slug, + string TimeZone); + +public class CreateWorkspaceRequestValidator + : Validator +{ + public CreateWorkspaceRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Slug) + .NotEmpty() + .MaximumLength(128) + .Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); + } +} + +public class CreateWorkspaceHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/workspaces"); + Options(o => o.WithTags("Workspaces")); + } + + public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct) + { + if (!accessScopeService.IsManager(User)) + { + await SendForbiddenAsync(ct); + return; + } + + string normalizedName = request.Name.Trim(); + string normalizedSlug = request.Slug.Trim().ToLowerInvariant(); + string normalizedTimeZone = request.TimeZone.Trim(); + + bool duplicateWorkspace = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Slug == normalizedSlug, ct); + + if (duplicateWorkspace) + { + AddError(request => request.Slug, "A workspace with this slug already exists."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + Workspace workspace = new() + { + Id = Guid.NewGuid(), + Name = normalizedName, + Slug = normalizedSlug, + OwnerUserId = User.GetUserId(), + TimeZone = normalizedTimeZone, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Workspaces.Add(workspace); + await dbContext.SaveChangesAsync(ct); + + WorkspaceDto dto = new( + workspace.Id, + workspace.Name, + workspace.Slug, + workspace.TimeZone, + workspace.CreatedAt); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs b/backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs new file mode 100644 index 0000000..cd1c3f7 --- /dev/null +++ b/backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs @@ -0,0 +1,100 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Identity.Contracts; +using Socialize.Modules.Workspaces.Data; + +namespace Socialize.Modules.Workspaces.Handlers; + +public record CreateWorkspaceInviteRequest( + string Email, + string Role); + +public class CreateWorkspaceInviteRequestValidator + : Validator +{ + private static readonly string[] AllowedRoles = + [ + KnownRoles.Client, + KnownRoles.Provider, + KnownRoles.WorkspaceMember, + ]; + + public CreateWorkspaceInviteRequestValidator() + { + RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress(); + RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role)); + } +} + +public class CreateWorkspaceInviteHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/workspaces/{workspaceId:guid}/invites"); + Options(o => o.WithTags("Workspaces")); + } + + public override async Task HandleAsync(CreateWorkspaceInviteRequest request, CancellationToken ct) + { + Guid workspaceId = Route("workspaceId"); + + if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + bool workspaceExists = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Id == workspaceId, ct); + + if (!workspaceExists) + { + AddError("workspaceId", "The selected workspace does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + string normalizedEmail = request.Email.Trim().ToLowerInvariant(); + string normalizedRole = request.Role.Trim(); + + bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync( + invite => invite.WorkspaceId == workspaceId && + invite.Email == normalizedEmail && + invite.Status == "Pending", + ct); + + if (duplicateInvite) + { + AddError(request => request.Email, "A pending invite already exists for this email in the selected workspace."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + WorkspaceInvite invite = new() + { + Id = Guid.NewGuid(), + WorkspaceId = workspaceId, + Email = normalizedEmail, + Role = normalizedRole, + Status = "Pending", + InvitedByUserId = User.GetUserId(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.WorkspaceInvites.Add(invite); + await dbContext.SaveChangesAsync(ct); + + await SendAsync( + new WorkspaceInviteDto( + invite.Id, + invite.WorkspaceId, + invite.Email, + invite.Role, + invite.Status, + invite.CreatedAt), + StatusCodes.Status201Created, + ct); + } +} diff --git a/backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs b/backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs new file mode 100644 index 0000000..2d2a390 --- /dev/null +++ b/backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs @@ -0,0 +1,49 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Workspaces.Data; + +namespace Socialize.Modules.Workspaces.Handlers; + +public record WorkspaceInviteDto( + Guid Id, + Guid WorkspaceId, + string Email, + string Role, + string Status, + DateTimeOffset CreatedAt); + +public class GetWorkspaceInvitesHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/workspaces/{workspaceId:guid}/invites"); + Options(o => o.WithTags("Workspaces")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid workspaceId = Route("workspaceId"); + + if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + List invites = await dbContext.WorkspaceInvites + .Where(invite => invite.WorkspaceId == workspaceId) + .OrderByDescending(invite => invite.CreatedAt) + .Select(invite => new WorkspaceInviteDto( + invite.Id, + invite.WorkspaceId, + invite.Email, + invite.Role, + invite.Status, + invite.CreatedAt)) + .ToListAsync(ct); + + await SendOkAsync(invites, ct); + } +} diff --git a/backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs b/backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs new file mode 100644 index 0000000..3c3b36f --- /dev/null +++ b/backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using Socialize.Infrastructure.Security; + +namespace Socialize.Modules.Workspaces.Handlers; + +public record WorkspaceMemberDto( + Guid Id, + string DisplayName, + string Email, + string? PortraitUrl, + IReadOnlyCollection Roles); + +public class GetWorkspaceMembersHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/workspaces/{workspaceId:guid}/members"); + Options(o => o.WithTags("Workspaces")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid workspaceId = Route("workspaceId"); + + if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + { + await SendForbiddenAsync(ct); + return; + } + + string workspaceClaimValue = workspaceId.ToString(); + + List users = await dbContext.Users + .Where(candidate => + dbContext.UserClaims.Any(claim => + claim.UserId == candidate.Id && + claim.ClaimType == KnownClaims.WorkspaceScope && + claim.ClaimValue == workspaceClaimValue)) + .OrderBy(candidate => candidate.Lastname) + .ThenBy(candidate => candidate.Firstname) + .ThenBy(candidate => candidate.Email) + .ToListAsync(ct); + + List userIds = users + .Select(candidate => candidate.Id) + .ToList(); + + Dictionary> rolesByUserId = await dbContext.UserRoles + .Where(candidate => userIds.Contains(candidate.UserId)) + .Join( + dbContext.Roles, + userRole => userRole.RoleId, + role => role.Id, + (userRole, role) => new { userRole.UserId, role.Name }) + .GroupBy(candidate => candidate.UserId) + .ToDictionaryAsync( + group => group.Key, + group => (IReadOnlyCollection)group + .Select(candidate => candidate.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .OrderBy(name => name) + .ToArray(), + ct); + + List members = users + .Select(candidate => new WorkspaceMemberDto( + candidate.Id, + BuildDisplayName(candidate), + candidate.Email ?? string.Empty, + candidate.PortraitUrl, + rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty())) + .ToList(); + + await SendOkAsync(members, ct); + } + + private static string BuildDisplayName(User user) + { + if (!string.IsNullOrWhiteSpace(user.Alias)) + { + return user.Alias; + } + + string fullName = $"{user.Firstname} {user.Lastname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) + { + return fullName; + } + + return user.Email ?? user.UserName ?? user.Id.ToString(); + } +} diff --git a/backend/Modules/Workspaces/Handlers/GetWorkspaces.cs b/backend/Modules/Workspaces/Handlers/GetWorkspaces.cs new file mode 100644 index 0000000..3018d98 --- /dev/null +++ b/backend/Modules/Workspaces/Handlers/GetWorkspaces.cs @@ -0,0 +1,46 @@ +using Socialize.Infrastructure.Security; +using Socialize.Modules.Workspaces.Data; + +namespace Socialize.Modules.Workspaces.Handlers; + +public record WorkspaceDto( + Guid Id, + string Name, + string Slug, + string TimeZone, + DateTimeOffset CreatedAt); + +public class GetWorkspacesHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/workspaces"); + Options(o => o.WithTags("Workspaces")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + IQueryable query = dbContext.Workspaces.AsQueryable(); + + if (!accessScopeService.IsManager(User)) + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id)); + } + + List workspaces = await query + .OrderBy(workspace => workspace.Name) + .Select(workspace => new WorkspaceDto( + workspace.Id, + workspace.Name, + workspace.Slug, + workspace.TimeZone, + workspace.CreatedAt)) + .ToListAsync(ct); + + await SendOkAsync(workspaces, ct); + } +} diff --git a/backend/Program.cs b/backend/Program.cs index d912e93..907ccf5 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,18 +1,17 @@ using Azure.Identity; -using Hutopy; -using Hutopy.Infrastructure; -using Hutopy.Modules.Contents; -using Hutopy.Modules.Contents.Data; -using Hutopy.Modules.Creators; -using Hutopy.Modules.Creators.Data; -using Hutopy.Modules.Identity; -using Hutopy.Modules.Identity.Data; -using Hutopy.Modules.Memberships; -using Hutopy.Modules.Memberships.Data; -using Hutopy.Modules.Messaging; -using Hutopy.Modules.Messaging.Data; -using Hutopy.Modules.Tipping; -using Hutopy.Modules.Tipping.Data; +using Socialize; +using Socialize.Data; +using Socialize.Infrastructure; +using Socialize.Infrastructure.Development; +using Socialize.Modules.Approvals; +using Socialize.Modules.Assets; +using Socialize.Modules.Clients; +using Socialize.Modules.Comments; +using Socialize.Modules.ContentItems; +using Socialize.Modules.Identity; +using Socialize.Modules.Notifications; +using Socialize.Modules.Projects; +using Socialize.Modules.Workspaces; using Microsoft.AspNetCore.HttpOverrides; using NSwag; using NSwag.Generation.AspNetCore.Processors; @@ -52,7 +51,7 @@ builder.Services.AddOpenApiDocument(( configure, _) => { - configure.Title = "Hutopy API"; + configure.Title = "Socialize API"; // Add JWT configure.AddSecurity( @@ -75,30 +74,17 @@ string postgresConnectionString = builder.Configuration.GetConnectionString("Pos "Missing ConnectionStrings:PostgresConnection"); builder.Services.AddFastEndpoints(); +builder.Services.AddAppData(postgresConnectionString); builder.AddInfrastructureModule(); -builder.AddIdentityModule(options => - options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", IdentityDbContext.SchemaName))); -builder.AddCreatorModule(options => - options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", CreatorsDbContext.SchemaName))); -builder.AddContentModule(options => - options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", ContentsDbContext.SchemaName))); -builder.AddMembershipModule(options => options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipsDbContext.SchemaName))); -builder.AddTippingModule(options => - options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", TippingDbContext.SchemaName))); -builder.AddMessagingModule(options => - options.UseNpgsql( - postgresConnectionString, - o => o.MigrationsHistoryTable("__EFMigrationsHistory", MessagingDbContext.SchemaName))); +builder.AddIdentityModule(); +builder.AddWorkspaceModule(); +builder.AddClientsModule(); +builder.AddProjectsModule(); +builder.AddContentItemsModule(); +builder.AddAssetsModule(); +builder.AddCommentsModule(); +builder.AddApprovalsModule(); +builder.AddNotificationsModule(); WebApplication app = builder.Build(); @@ -112,12 +98,9 @@ app.UseAuthentication(); app.UseAuthorization(); // Initialize and seed the db. +await app.UseAppDataAsync(); await app.UseIdentityModuleAsync(); -await app.UseCreatorModuleAsync(); -await app.UseContentModuleAsync(); -await app.UseMembershipModuleAsync(); -await app.UseTippingModuleAsync(); -await app.UseMessagingModuleAsync(); +await app.UseDevelopmentSeedAsync(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) @@ -127,7 +110,11 @@ if (!app.Environment.IsDevelopment()) } app.UseHealthChecks("/health"); -app.UseHttpsRedirection(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} if (app.Environment.IsDevelopment()) { diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json index f3a4f81..fcc2c5e 100644 --- a/backend/Properties/launchSettings.json +++ b/backend/Properties/launchSettings.json @@ -1,11 +1,11 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { - "Hutopy.Web - DEV": { + "Socialize.Api - DEV": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000", + "applicationUrl": "http://0.0.0.0:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/backend/Hutopy.csproj b/backend/Socialize.Api.csproj similarity index 80% rename from backend/Hutopy.csproj rename to backend/Socialize.Api.csproj index 4e9c8c3..aac2906 100644 --- a/backend/Hutopy.csproj +++ b/backend/Socialize.Api.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85 @@ -14,22 +14,22 @@ - - - + + + - - - - - - - - - + + + + + + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -37,7 +37,7 @@ - + diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index e724bc5..2ec502b 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -1,19 +1,25 @@ { "ConnectionStrings": { - "PostgresConnection": "Server=localhost,5432;Database=hutopy;Uid=sa;Pwd=P@ssword123!;" + "PostgresConnection": "Host=localhost;Port=5433;Database=socialize;Username=sa;Password=P@ssword123!;" }, "Stripe": { "SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI", "WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1", - "HutopyRate": 0.05 + "SocializeRate": 0.05 }, "Website": { - "FrontendBaseUrl": "https://localhost:5173" + "FrontendBaseUrl": "http://localhost:5173" }, "Authentication": { "Jwt": { + "Issuer": "http://localhost:5000", + "Audience": "socialize-local", + "Key": "socialize-dev-local-signing-key-please-change", "Lifetime": "00:05:00", "RefreshTokenLifetime": "0.00:30:00" } + }, + "DevelopmentSeed": { + "Enabled": true } } diff --git a/backend/appsettings.Production.json b/backend/appsettings.Production.json index 39d118e..863b7a1 100644 --- a/backend/appsettings.Production.json +++ b/backend/appsettings.Production.json @@ -3,7 +3,7 @@ "PostgresConnection": "Server=hutopypostgress.postgres.database.azure.com,5432;Database=hutopy;User Id=hutopy;Password=General2024!;Ssl Mode=Require;" }, "Stripe": { - "HutopyRate": 0.05 + "SocializeRate": 0.05 }, "Website": { "FrontendBaseUrl": "https://hutopy.com" diff --git a/backend/appsettings.json b/backend/appsettings.json index 237da36..3cf03db 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -13,7 +13,7 @@ }, "Emailer": { "ApiKey": "re_HPQGrtF8_7xqFQnhyrp5GeseXnZ9pKd4q", - "FromEmail": "Hutopy " + "FromEmail": "Socialize " }, "Authentication": { "Jwt": { diff --git a/backend/backend.sln b/backend/backend.sln index c1912dc..f90568c 100644 --- a/backend/backend.sln +++ b/backend/backend.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hutopy", "Hutopy.csproj", "{D790B528-6968-4CCD-A25D-A108A82CBDAC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Socialize.Api", "Socialize.Api.csproj", "{D790B528-6968-4CCD-A25D-A108A82CBDAC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/backend/scripts/add-migration.sh b/backend/scripts/add-migration.sh index b1760f7..ac6fb74 100755 --- a/backend/scripts/add-migration.sh +++ b/backend/scripts/add-migration.sh @@ -11,7 +11,7 @@ if [ -z "$MODULE_NAME" ] || [ -z "$MIGRATION_NAME" ]; then fi dotnet ef migrations add \ - --context "Hutopy.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" \ + --context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" \ --configuration Debug \ --output-dir "Modules/${MODULE_NAME}/Migrations" \ "$MIGRATION_NAME" diff --git a/backend/scripts/start-infrastructure.sh b/backend/scripts/start-infrastructure.sh index 31e8039..ce89696 100755 --- a/backend/scripts/start-infrastructure.sh +++ b/backend/scripts/start-infrastructure.sh @@ -1,25 +1,24 @@ #!/bin/bash # Start the container (if not already running) -docker start postgres 2>/dev/null || docker run \ +docker start socialize-postgres 2>/dev/null || docker run \ --cap-add SYS_PTRACE \ -e POSTGRES_USER=sa \ -e POSTGRES_PASSWORD='P@ssword123!' \ - -p 5432:5432 \ - --name postgres \ + -p 5433:5432 \ + --name socialize-postgres \ -d postgres:latest # Wait until Postgres is ready echo "Waiting for Postgres to be ready..." -until docker exec postgres pg_isready -U sa > /dev/null 2>&1; do +until docker exec socialize-postgres pg_isready -U sa >/dev/null 2>&1; do sleep 1 done -# Create 'hutopy' database if it doesn't exist -echo "Ensuring 'hutopy' database exists..." -docker exec -e PGPASSWORD='P@ssword123!' postgres \ - psql -U sa -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='hutopy'" | grep -q 1 || \ - docker exec -e PGPASSWORD='P@ssword123!' postgres \ - createdb -U sa hutopy +# Create databases if they don't exist +echo "Ensuring development databases exist..." + +docker exec -e PGPASSWORD='P@ssword123!' socialize-postgres \ + sh -lc "psql -U sa -d postgres -tAc \"SELECT 1 FROM pg_database WHERE datname='socialize'\" | grep -q 1 || createdb -U sa socialize" echo "✅ Done." diff --git a/backend/scripts/update-databases.sh b/backend/scripts/update-databases.sh index 5fd2ad0..5917e5c 100755 --- a/backend/scripts/update-databases.sh +++ b/backend/scripts/update-databases.sh @@ -14,7 +14,7 @@ fi UPDATE_COMMAND=( dotnet ef database update - --context "Hutopy.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" + --context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" --configuration Debug ) diff --git a/docs/LLM_DEVELOPMENT_WORKFLOW.md b/docs/LLM_DEVELOPMENT_WORKFLOW.md new file mode 100644 index 0000000..556a861 --- /dev/null +++ b/docs/LLM_DEVELOPMENT_WORKFLOW.md @@ -0,0 +1,593 @@ +# LLM Development Workflow + +## Purpose +This document defines how to use coding agents such as Codex, Claude Code, ChatGPT, or similar tools on this repository without losing architectural coherence over time. + +The core rule is simple: + +> The repository is the source of truth. The chat is only a temporary execution surface. + +Coding agents should not rely on long conversation history to understand the product, architecture, or current task. Important context must live in versioned files inside the repository. + +## Why This Workflow Exists + +LLM coding tools are very strong at creating an initial project or implementing isolated changes. They become less reliable when the work shifts from greenfield generation to incremental product engineering. + +The common failure mode is this: + +1. The first prompt creates a large amount of useful code. +2. Later prompts become increasingly specific and corrective. +3. The agent starts forgetting prior decisions, duplicating patterns, inventing architecture, or changing unrelated files. +4. Most of the work becomes re-explaining context instead of shipping features. + +This does not mean the tools are useless. It means the workflow must change. + +Early-stage prompting can be broad. Mature product work must be driven by stable docs, scoped tasks, acceptance criteria, and reviewable diffs. + +## Operating Model + +Use coding agents as fast implementation partners, not as autonomous product owners. + +Agents should: + +- read repository documentation first; +- understand the current task before editing; +- propose a small implementation plan; +- make minimal, reviewable changes; +- preserve existing architecture and conventions; +- report risks, assumptions, and validation steps. + +Agents should not: + +- use chat history as the primary source of truth; +- invent product behavior when docs are missing; +- perform broad rewrites during a feature task; +- silently change unrelated files; +- mix backend, frontend, database, UX, and refactoring work without a scoped plan. + +## Repository Documentation Model + +The repository should contain a small set of durable documents that agents always consult. + +Recommended structure: + +```text +docs/ + PRODUCT.md + ARCHITECTURE.md + CONVENTIONS.md + DECISIONS.md + BACKLOG.md + LLM_DEVELOPMENT_WORKFLOW.md + tasks/ + TASK-001-example.md + TASK-002-example.md +AGENTS.md +``` + +### `AGENTS.md` + +`AGENTS.md` is the entry point for coding agents. + +It should tell agents: + +- how to work in this repo; +- what docs to read first; +- how the backend and frontend are structured; +- what commands to run; +- what rules are non-negotiable; +- how to validate work before finishing. + +This file should stay concise enough that agents actually follow it. + +### `docs/PRODUCT.md` + +Defines the product from the user's perspective. + +Include: + +- what the product is; +- target users; +- major workflows; +- important UX principles; +- feature boundaries; +- what the product explicitly does not do yet. + +Use this file to prevent agents from inventing product behavior. + +### `docs/ARCHITECTURE.md` + +Defines the technical structure. + +Include: + +- backend architecture; +- frontend architecture; +- module boundaries; +- authentication model; +- data ownership; +- routing model; +- state management model; +- API integration patterns. + +Use this file to prevent agents from inventing new architecture for each task. + +### `docs/CONVENTIONS.md` + +Defines repeatable implementation patterns. + +Include: + +- C# style and backend endpoint conventions; +- FastEndpoints patterns; +- EF Core migration rules; +- Vue component conventions; +- Pinia store conventions; +- API client usage; +- validation and error handling patterns; +- testing expectations; +- naming conventions. + +Use this file to reduce code style drift. + +### `docs/DECISIONS.md` + +A lightweight architecture decision log. + +Each entry should capture: + +- date; +- decision; +- context; +- consequences. + +Example: + +```md +## 2026-04-23 — Runtime frontend config is centralized + +Decision: Frontend runtime configuration must be accessed through `frontend/src/config.js`. + +Context: Direct `import.meta.env` reads scattered across feature code make configuration harder to audit. + +Consequence: New frontend code should import config from `frontend/src/config.js` instead of reading env variables directly. +``` + +Use this file to prevent agents from revisiting settled choices. + +### `docs/BACKLOG.md` + +A human-readable backlog of possible future work. + +Use this for ideas, improvements, bugs, and deferred refactors. + +Important rule: + +> If an agent sees an adjacent problem that is outside the current task, it should add or suggest a backlog item instead of fixing it opportunistically. + +### `docs/tasks/TASK-xxx.md` + +Task files are the LLM-friendly equivalent of Jira tickets. + +They are not just backend tickets. A product feature is usually fullstack and should describe backend, frontend, UX behavior, data model, validation, and acceptance criteria. + +The task file should be specific enough that a fresh agent can implement it without reading a long chat thread. + +## Task File Template + +Use this template for implementation tasks. + +```md +# TASK-000 — Short Feature Name + +## Status +Draft | Ready | In Progress | Done | Blocked + +## Objective +Describe the user-visible or technical outcome in 1-3 sentences. + +## Product Context +Explain why this exists and where it fits in the product workflow. + +## Scope +- What this task includes. +- Keep this list concrete. + +## Out of Scope +- What this task explicitly must not include. +- Add adjacent ideas here to prevent scope creep. + +## Existing References +Agents must inspect these before implementation: +- `docs/PRODUCT.md` +- `docs/ARCHITECTURE.md` +- `docs/CONVENTIONS.md` +- relevant existing backend files +- relevant existing frontend files + +## Backend Requirements +### API Contract +Endpoint: +- `METHOD /api/example` + +Request: +```json +{ + "example": "value" +} +``` + +Response: +```json +{ + "id": "string" +} +``` + +### Validation +- Rule 1 +- Rule 2 + +### Data / Persistence +- Entity changes, if any. +- DbContext/module affected, if any. +- Migration required: yes/no. + +### Security / Authorization +- Authentication required: yes/no. +- Roles or workspace access rules. + +## Frontend Requirements +### Route / Screen +- Route: `/app/example` +- View file: `frontend/src/views/app/ExampleView.vue` + +### Components +- `ExampleForm.vue` +- `ExampleList.vue` + +### State Management +- Pinia store used or created. +- Existing store actions to reuse. + +### API Integration +- Existing API client usage. +- Expected loading, success, and error behavior. + +### UX Behavior +- Empty state. +- Loading state. +- Validation display. +- Toasts or inline errors. +- Navigation after success, if any. + +## Files Likely Involved +Backend: +- `backend/...` + +Frontend: +- `frontend/...` + +Docs: +- `docs/...` + +## Acceptance Criteria +- User can complete the intended workflow. +- Backend validates invalid input correctly. +- Frontend displays loading and error states correctly. +- Existing auth, workspace scoping, and refresh behavior are preserved. +- Build passes. + +## Validation Plan +Backend: +- `cd backend && dotnet build Socialize.Api.csproj` +- Additional manual or automated checks. + +Frontend: +- `cd frontend && npm run build` +- Additional manual route/store checks. + +## Risks / Edge Cases +- Risk 1 +- Risk 2 + +## Open Questions +- Question 1 +``` + +## Feature Definition: Fullstack by Default + +A feature should normally be defined across these dimensions: + +1. Product workflow: what the user is trying to accomplish. +2. UX behavior: screens, states, feedback, errors, navigation. +3. Frontend implementation: routes, components, stores, API calls. +4. Backend implementation: endpoints, validation, persistence, authorization. +5. Data model: entities, migrations, ownership boundaries. +6. Acceptance criteria: what must be true before the task is done. +7. Validation plan: commands and manual checks. + +Avoid defining features as backend-only unless the task is explicitly backend-only. + +## Recommended Agent Workflow + +Use this sequence for non-trivial changes. + +### 1. Prepare or update the task file + +Create a `docs/tasks/TASK-xxx.md` file before asking an agent to implement a feature. + +The task can be rough at first, but it must state: + +- objective; +- scope; +- out of scope; +- backend requirements, if any; +- frontend requirements, if any; +- acceptance criteria. + +### 2. Start a fresh agent thread per task + +Do not run an entire project through one endless chat. + +Use one thread for one task or one small group of tightly related subtasks. + +### 3. Ask for analysis before implementation + +Default prompt: + +```text +Read AGENTS.md and the relevant docs first. +Then read docs/tasks/TASK-000-short-name.md. +Do not edit files yet. +Summarize the task, identify the relevant backend and frontend files, list risks, and propose a minimal implementation plan. +``` + +### 4. Implement one bounded step at a time + +Default prompt: + +```text +Implement only step 1 from the plan. +Keep the diff minimal. +Do not refactor unrelated code. +Do not change public behavior outside this task. +At the end, summarize changes and list validation steps. +``` + +### 5. Review the diff + +The repository owner reviews: + +- scope creep; +- architectural drift; +- duplicated code; +- inconsistent UI behavior; +- unsafe auth or workspace scoping changes; +- broken conventions. + +### 6. Validate + +Run the relevant commands from `AGENTS.md` and the task file. + +Minimum expected validation: + +- backend build for backend changes; +- frontend build for frontend changes; +- affected user flow checked manually when UI behavior changes. + +### 7. Update docs if the task changed architecture or product behavior + +If implementation creates a durable decision, update `docs/DECISIONS.md`. + +If implementation changes structure, update `docs/ARCHITECTURE.md`. + +If implementation changes conventions, update `docs/CONVENTIONS.md`. + +If implementation changes user-facing behavior, update `docs/PRODUCT.md`. + +## Prompt Patterns + +### Planning Prompt + +```text +You are working in an existing repository. + +First read: +- AGENTS.md +- docs/PRODUCT.md +- docs/ARCHITECTURE.md +- docs/CONVENTIONS.md +- docs/DECISIONS.md +- docs/tasks/TASK-000-short-name.md + +Do not edit files yet. + +Return: +1. summary of the task; +2. relevant existing files; +3. backend impact; +4. frontend impact; +5. risks and ambiguities; +6. minimal implementation plan; +7. validation plan. +``` + +### Implementation Prompt + +```text +Implement the task from docs/tasks/TASK-000-short-name.md. + +Rules: +- follow AGENTS.md; +- follow docs/ARCHITECTURE.md and docs/CONVENTIONS.md; +- keep the diff minimal; +- do not refactor unrelated code; +- preserve auth, workspace scoping, token refresh, and runtime config patterns; +- if you discover missing requirements, stop and report them instead of inventing behavior. + +At the end, provide: +- files changed; +- summary of behavior added; +- validation performed; +- validation still needed; +- risks or follow-up items. +``` + +### Frontend-Only Prompt + +```text +Implement only the frontend part of docs/tasks/TASK-000-short-name.md. + +Do not modify backend code. +Use existing routes, stores, API client, UI framework, and runtime config conventions. +Preserve loading, error, empty, and success states described in the task. +``` + +### Backend-Only Prompt + +```text +Implement only the backend part of docs/tasks/TASK-000-short-name.md. + +Do not modify frontend code. +Follow the existing FastEndpoints, validation, module, DbContext, and migration conventions. +Preserve auth and workspace scoping rules. +``` + +### Review Prompt + +```text +Review the current diff against: +- AGENTS.md +- docs/ARCHITECTURE.md +- docs/CONVENTIONS.md +- docs/tasks/TASK-000-short-name.md + +Look specifically for: +- scope creep; +- architectural drift; +- broken frontend state patterns; +- broken backend module boundaries; +- auth or workspace scoping regressions; +- missing loading/error states; +- missing validation; +- missing tests or manual checks. + +Do not edit files. Return findings only. +``` + +## Rules for Frontend Work + +Frontend changes must be explicit in the task. + +A frontend task should identify: + +- route or screen; +- components to create or modify; +- Pinia stores involved; +- API calls involved; +- loading state; +- error state; +- empty state; +- success feedback; +- navigation behavior; +- role/access behavior; +- responsive or layout expectations, if relevant. + +Agents must not invent a new frontend architecture for a single feature. + +They should reuse: + +- existing Vue patterns; +- existing Vuetify/Tailwind conventions; +- existing Pinia stores where appropriate; +- existing Axios client; +- existing router guards; +- existing toast/error patterns. + +## Rules for Backend Work + +Backend changes must respect module boundaries. + +A backend task should identify: + +- endpoint route and method; +- request and response shape; +- validation rules; +- module ownership; +- DbContext affected; +- migration requirement; +- authorization rules; +- workspace scoping rules; +- expected errors. + +Agents must not couple module DbContexts or bypass existing auth/security helpers. + +## Handling Ambiguity + +When a requirement is unclear, agents should not silently invent behavior. + +Preferred behavior: + +1. State the ambiguity. +2. Propose the safest default. +3. Keep implementation narrow. +4. Add an open question or backlog item if needed. + +For small ambiguities that do not affect architecture or product behavior, the agent may choose the safest existing pattern and document the assumption. + +For large ambiguities, the agent should stop before implementation. + +## Handling Refactors + +Refactors should be separate tasks unless they are strictly required for the current feature. + +If a refactor is needed, the task should state: + +- why the refactor is necessary; +- what files are in scope; +- what behavior must remain unchanged; +- how to validate no regression occurred. + +Agents should not do opportunistic cleanup in unrelated files. + +## Handling Backlog Items + +When an agent notices an issue outside scope, it should not fix it by default. + +It should report it as: + +```md +Suggested backlog item: +- Title: +- Reason: +- Affected files: +- Risk if ignored: +``` + +The repository owner can then decide whether to create a task. + +## Definition of Done + +A task is done when: + +- implementation matches the task file; +- scope and out-of-scope boundaries were respected; +- relevant backend and/or frontend validation passed; +- user-facing behavior was manually checked when applicable; +- docs were updated if durable behavior or architecture changed; +- remaining risks or follow-up items were explicitly listed. + +## Practical Mental Model + +Use this model: + +- `AGENTS.md` tells the agent how to behave. +- `docs/PRODUCT.md` tells the agent what the product is. +- `docs/ARCHITECTURE.md` tells the agent how the system is shaped. +- `docs/CONVENTIONS.md` tells the agent how to write code here. +- `docs/DECISIONS.md` tells the agent what choices are already settled. +- `docs/BACKLOG.md` stores ideas that are not current work. +- `docs/tasks/TASK-xxx.md` tells the agent what to do now. + +The more this information lives in the repo, the less each new prompt has to reconstruct the project from memory. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dc463ae --- /dev/null +++ b/docs/README.md @@ -0,0 +1,32 @@ +# Docs + +This folder contains the project documentation used to guide product, implementation, and architecture work. + +## Current Product Docs + +- [product/vision.md](/home/jbourdon/repos/social-media/docs/product/vision.md): current product intent, users, scope, and priorities. +- [product/glossary.md](/home/jbourdon/repos/social-media/docs/product/glossary.md): canonical domain vocabulary. Prefer these terms in code, UI copy, and specs. +- [constraints.md](/home/jbourdon/repos/social-media/docs/constraints.md): business and technical invariants that should not be violated casually. + +## Current Delivery Docs + +- [use-cases/review-workflows.md](/home/jbourdon/repos/social-media/docs/use-cases/review-workflows.md): scenario-driven workflow descriptions. +- [specs/content-approval-workflow.md](/home/jbourdon/repos/social-media/docs/specs/content-approval-workflow.md): structured feature spec for the primary workflow. +- [specs/TEMPLATE.md](/home/jbourdon/repos/social-media/docs/specs/TEMPLATE.md): template for future feature specs. + +## Decision Records + +- [decisions/README.md](/home/jbourdon/repos/social-media/docs/decisions/README.md): ADR conventions and when to add a decision record. +- [decisions/ADR-TEMPLATE.md](/home/jbourdon/repos/social-media/docs/decisions/ADR-TEMPLATE.md): template for new architecture or product decisions. + +## Archived + +- [archive/PLAN.md](/home/jbourdon/repos/social-media/docs/archive/PLAN.md): early pivot plan; useful for historical context, but stale as an execution checklist. +- [archive/SOCIALIZE.md](/home/jbourdon/repos/social-media/docs/archive/SOCIALIZE.md): original consolidated product brief before the docs were split into focused source-of-truth files. +- [archive/Stripe.md](/home/jbourdon/repos/social-media/docs/archive/Stripe.md): legacy Hutopy monetization notes for memberships and tips. +- [archive/WORKSHEET.md](/home/jbourdon/repos/social-media/docs/archive/WORKSHEET.md): historical implementation worksheet from an earlier transition phase. + +## Root Docs + +- [README.md](/home/jbourdon/repos/social-media/README.md): repository overview and local development setup. +- [AGENTS.md](/home/jbourdon/repos/social-media/AGENTS.md): working guide for coding agents. This stays at repo root intentionally. diff --git a/PLAN.md b/docs/archive/PLAN.md similarity index 98% rename from PLAN.md rename to docs/archive/PLAN.md index bc44df7..b579efc 100644 --- a/PLAN.md +++ b/docs/archive/PLAN.md @@ -1,8 +1,10 @@ # PLAN +> Historical planning snapshot. This document reflects the early product-pivot plan and is not the source of truth for the current implementation state. + ## Purpose -This document defines the build plan to close the gap between the current codebase and the target product described in [SOCIALIZE.md](/home/jbourdon/repos/social-media/SOCIALIZE.md). +This document defines the build plan to close the gap between the current codebase and the target product described in [SOCIALIZE.md](/home/jbourdon/repos/social-media/docs/archive/SOCIALIZE.md). The current repository is a dead `Hutopy` codebase. The target product, temporarily named `Socialize`, is a workflow application for social media content review, revision, approval, and readiness for publication. diff --git a/SOCIALIZE.md b/docs/archive/SOCIALIZE.md similarity index 100% rename from SOCIALIZE.md rename to docs/archive/SOCIALIZE.md diff --git a/Stripe.md b/docs/archive/Stripe.md similarity index 84% rename from Stripe.md rename to docs/archive/Stripe.md index 5b99738..3bcedcc 100644 --- a/Stripe.md +++ b/docs/archive/Stripe.md @@ -1,5 +1,7 @@ # Stripe +> Legacy Hutopy-era notes. These flows describe the old creator membership and tipping model and do not match the current workflow product. + ## Events Workflow ### Membership diff --git a/docs/archive/WORKSHEET.md b/docs/archive/WORKSHEET.md new file mode 100644 index 0000000..5dd6d9e --- /dev/null +++ b/docs/archive/WORKSHEET.md @@ -0,0 +1,35 @@ +> Historical worksheet from the product-pivot phase. Several items are completed, renamed, or superseded by the current codebase. + +What is left to do: + + 1. Replace frontend seeded stores with real API-backed stores. + 2. Build authenticated loading for: + - workspaces + - clients + - projects + - content items + 3. Add create flows: + - create workspace + - create client + - create project + - create content item + 4. Build the first real content-item detail page. + 5. Add the actual approval workflow domain: + - approval requests + - approval decisions + - status transitions + 6. Add comments and revision tracking. + 7. Add Google Drive asset linkage instead of placeholder source fields only. + 8. Add notification/event backbone. + 9. Remove or retire old Hutopy modules and routes once the new vertical slice replaces them. + 10. Rename remaining Hutopy namespaces/product strings in the backend if you want the codebase semantics + cleaned up now instead of later. + + + So the main remaining substantive work is: + +1. finish full-site localization +2. build real integrations backend for Google Drive and API keys +3. deepen workspace/settings management +4. continue workflow polish and broader comment/thread modeling if you want that + generalized again diff --git a/docs/constraints.md b/docs/constraints.md new file mode 100644 index 0000000..76bbb29 --- /dev/null +++ b/docs/constraints.md @@ -0,0 +1,44 @@ +# Constraints + +These are cross-cutting rules for the current product and codebase. They are intended to reduce ambiguity for both humans and AI agents. + +## Product Constraints + +- The product is a workflow tool, not a public social network. +- Internal review and client review should be modeled as related workflow stages, not isolated products. +- Content approval must preserve traceability of comments, revisions, decisions, and status changes. +- “Ready to publish” should only be reachable from an explicit workflow state transition, not inferred casually in UI code. + +## Domain Constraints + +- `Workspace` is the top-level scoping boundary. +- An agency may manage multiple workspaces. +- `ContentItem` belongs to a workspace scope. +- Comments, approvals, assets, and notifications must remain traceable to the underlying workflow entity they relate to. + +## Backend Constraints + +- Keep module boundaries intact. Do not couple DbContexts across modules. +- When adding schema changes, create migrations in the owning module only. +- Follow the existing FastEndpoints handler pattern with explicit route and tag metadata. +- Preserve development HTTP behavior locally; HTTPS redirection is not enabled in development. + +## Frontend Constraints + +- Frontend runtime config must flow through `frontend/src/config.js`. +- Do not add feature-level `import.meta.env` reads. +- Avoid introducing fallback env chains that create multiple configuration sources of truth. +- Preserve token refresh concurrency protections in `authStore` and the API client. +- Preserve route-level auth and role checks unless the product requirement explicitly changes. + +## Documentation Constraints + +- One topic should have one current source of truth. +- Historical docs must be marked archived or legacy. +- Specs should distinguish current behavior from proposed behavior. +- Open questions should be listed explicitly rather than hidden in narrative text. + +## Naming Constraints + +- Prefer current product/domain language over Hutopy-era terminology. +- Avoid reviving creator/tipping/membership concepts unless intentionally rebuilding them for the new product. diff --git a/docs/decisions/ADR-TEMPLATE.md b/docs/decisions/ADR-TEMPLATE.md new file mode 100644 index 0000000..bdf5c87 --- /dev/null +++ b/docs/decisions/ADR-TEMPLATE.md @@ -0,0 +1,31 @@ +# ADR-XXX: Title + +## Status + +Proposed | Accepted | Superseded + +## Context + +What problem or choice exists? + +## Options Considered + +### Option 1 + +- pros +- cons + +### Option 2 + +- pros +- cons + +## Decision + +State the chosen option clearly. + +## Consequences + +- positive consequence +- tradeoff +- follow-up implication diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..98741cc --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,26 @@ +# Decisions + +Use this folder for Architecture Decision Records (ADRs) and similarly durable implementation/product decisions. + +Add a decision record when: + +- a choice is likely to be revisited later +- multiple plausible options exist +- the team wants to preserve why a decision was made +- future agents or developers could otherwise repeat the same debate + +Typical decision topics: + +- external review access model +- Google Drive integration strategy +- workflow status model +- notification delivery model +- module boundaries +- approval rules + +Keep decisions short and explicit: + +- context +- options considered +- chosen option +- consequences diff --git a/docs/product/glossary.md b/docs/product/glossary.md new file mode 100644 index 0000000..d15628d --- /dev/null +++ b/docs/product/glossary.md @@ -0,0 +1,120 @@ +# Product Glossary + +Use these terms consistently in product docs, specs, UI copy, and code discussions. + +## Workspace + +Top-level working container for a client account. + +An agency may manage multiple workspaces. + +Use: + +- ownership boundary +- membership boundary +- scoping boundary for content items, assets, comments, approvals, and notifications + +Do not use as: + +- synonym for internal role +- synonym for team member role + +## Agency + +Operating organization managing one or more workspaces. + +An agency is above the workspace level in the business model. + +## Client + +Business, brand, or organization represented by a workspace and participating in review and approval flows. + +## Content Item + +Primary reviewable unit in the system. Contains metadata, copy, due dates, networks, channels, and linked assets. + +Examples: + +- one Instagram reel package +- one newsletter draft +- one campaign asset for approval + +## Asset + +A file or file reference attached to a content item, such as a video, image, or document. + +## Asset Revision + +Specific version of an asset with traceability to who linked or uploaded it and when. + +## Approval Workflow + +End-to-end process from draft creation to final approval and publishing handoff. + +## Approval Request + +Explicit request for one or more reviewers to review a specific content item or revision state. + +## Approval Decision + +Decision recorded by a reviewer, such as: + +- approved +- rejected +- changes requested + +## Reviewer + +Any person asked to review and approve work, whether internal or external. + +## External Reviewer + +Client or partner reviewing content without being part of the internal operating team. + +## Provider + +External production contributor such as a photographer, videographer, editor, or designer. + +## Comment Thread + +Contextual discussion attached to a content item, asset, or revision. + +## Status History + +Audit trail of workflow states and transitions over time. + +## Network + +Publishing platform or distribution surface, such as Instagram, Facebook, YouTube, LinkedIn, or a newsletter system. + +Examples: + +- TikTok +- Instagram +- Facebook +- YouTube +- LinkedIn +- Newsletter +- X +- YouTube + +## Channel + +Specific destination, account, handle, page, or feed within a network. + +Examples: + +- `@MyBrand` on Instagram +- `My Brand Page` on Facebook +- `#Brand` if used as an internal destination label +- a specific YouTube channel + +## Ready To Publish + +State meaning the required review/approval workflow is complete and the item can move to downstream publishing or handoff. + +It does not mean the item has already been published. + +## Audit Trail + +Chronological record of uploads, comments, approvals, and status changes. diff --git a/docs/product/vision.md b/docs/product/vision.md new file mode 100644 index 0000000..db5375e --- /dev/null +++ b/docs/product/vision.md @@ -0,0 +1,111 @@ +# Product Vision + +## Status + +Active + +## Product + +`Socialize` is a workflow application for social media content review, revision, approval, and publication readiness. + +It is not a public social network. It is a workspace-based coordination tool for internal teams, providers, and client approvers. + +## Problem + +Social media approval work is still fragmented across Google Drive, email, chat, phone calls, and spreadsheets. + +That fragmentation creates: + +- unclear latest-version ownership +- scattered comments and decisions +- manual follow-up work +- weak auditability +- avoidable delays before publication + +## Product Goal + +Provide one system of workflow for drafting, revising, reviewing, approving, and handing off content for publishing. + +## Primary Users + +- Social media manager +- Account manager / customer success +- Client approver +- External provider / production partner +- Internal producer +- Internal employee / content contributor +- Administrator + +## Core Product Shape + +- workspace-based account boundary +- agencies able to manage multiple workspaces +- content items as the main reviewable unit +- assets and revisions tracked against content items +- comments and approvals attached to the work itself +- notifications driven by workflow events + +## What The Product Must Do Well + +- centralize review state +- preserve a clear audit trail +- make “latest approved version” obvious +- support internal review before client review +- support client-facing review with low friction +- keep publication handoff clear once approval is complete + +## What The Product Is Not + +- not a social feed or public community platform +- not a full publishing engine in version 1 +- not a full DAM platform in version 1 +- not a full analytics product in version 1 +- not a billing/subscription product in version 1 + +## MVP Scope + +Version 1 should focus on approval workflow rather than direct publishing. + +### In Scope + +- authentication and user roles +- workspace structure and access control +- content item creation and editing +- Google Drive asset linkage and asset metadata +- revision history for assets and copy +- centralized comments +- approval decisions +- activity timeline / audit trail +- review queues and dashboards +- notifications and reminders +- simple external review experience + +### Out Of Scope + +- full scheduling engine +- direct social publishing +- advanced third-party synchronization +- analytics suite +- customer billing flows + +## Current Strategic Assumptions + +- Google Drive remains an important source of truth for many client-owned files. +- Internal review and client review are variants of the same workflow, not separate products. +- External reviewers should experience low-friction review access. + +## Near-Term Priorities + +- make the content approval workflow complete end-to-end +- harden workspace scoping and role-based access +- strengthen revision and comment traceability +- clarify external review and approval flows + +## Phase 2 Opportunities + +- deeper Google Drive integration +- publishing handoff integrations +- calendar planning views +- richer approval templates +- SLA reminders and escalations +- workflow analytics diff --git a/docs/specs/TEMPLATE.md b/docs/specs/TEMPLATE.md new file mode 100644 index 0000000..9f2af79 --- /dev/null +++ b/docs/specs/TEMPLATE.md @@ -0,0 +1,61 @@ +# Feature Name + +## Status + +Proposed | Active | Deprecated + +## Goal + +One short paragraph describing the outcome this feature must produce. + +## Actors + +- Actor 1 +- Actor 2 + +## Preconditions + +- precondition 1 +- precondition 2 + +## Trigger + +What starts this flow. + +## Main Flow + +1. User or system does something. +2. System validates. +3. System stores or updates state. +4. System emits responses, events, or notifications. + +## Alternate Flows + +- validation failure +- unauthorized actor +- missing dependency + +## Business Rules + +- rule 1 +- rule 2 + +## Data / Entities + +- entity 1 +- entity 2 + +## API / UI Surface + +- frontend routes +- backend endpoints +- roles or permissions + +## Acceptance Criteria + +- [ ] criterion 1 +- [ ] criterion 2 + +## Open Questions + +- question 1 diff --git a/docs/specs/content-approval-workflow.md b/docs/specs/content-approval-workflow.md new file mode 100644 index 0000000..fb58b72 --- /dev/null +++ b/docs/specs/content-approval-workflow.md @@ -0,0 +1,98 @@ +# Content Approval Workflow + +## Status + +Active + +## Goal + +Support the primary workflow from draft preparation through review, revision, approval decision, and readiness for publishing handoff. + +## Actors + +- Content contributor +- Provider +- Internal reviewer +- Manager +- Client approver + +## Preconditions + +- user is authenticated when acting as an internal team member +- work is scoped to a workspace +- content item exists inside a workspace context + +## Trigger + +A team member wants to send a content item through review and approval. + +## Main Flow + +1. A team member creates or updates a content item. +2. Assets are linked to the content item, including Google Drive references when appropriate. +3. The content item includes the relevant metadata: + - title + - publication message or caption + - networks + - channels + - due date + - notes +4. The item enters internal review or client review. +5. Reviewers leave comments and record decisions. +6. If changes are requested, the team links a new revision and continues the workflow. +7. Once required review is complete, the item can move to `Ready to publish`. + +## Alternate Flows + +- If a reviewer requests changes, the item should not be treated as approved. +- If the actor lacks required workspace access, the workflow action must be denied. +- If assets are missing, the item may still exist, but review readiness should remain explicit. + +## Business Rules + +- approvals and comments must remain attached to the content item context +- workflow state changes must be traceable +- revisions must not overwrite history invisibly +- “Ready to publish” must correspond to explicit workflow completion, not optimistic UI state + +## Data / Entities + +- Workspace +- ContentItem +- Asset +- AssetRevision +- CommentThread +- ApprovalRequest +- ApprovalDecision +- NotificationEvent + +## API / UI Surface + +### Frontend + +- `/app/content` +- `/app/content/:id` +- `/app/reviews` + +### Backend + +- content item handlers +- asset linkage / revision handlers +- approval handlers +- comment handlers +- notification handlers + +## Acceptance Criteria + +- [ ] a content item can carry the metadata needed for review +- [ ] assets and revisions are visible in the item history +- [ ] reviewers can leave comments and decisions in one place +- [ ] the audit trail makes status transitions understandable +- [ ] the approved state is distinguishable from changes-requested and rejected states +- [ ] the workflow supports internal review before client approval + +## Open Questions + +- Should external review be account-based, magic-link-based, or both in version 1? +- Which approval states are mandatory before transition to `Ready to publish`? +- Should required approvers be modeled in version 1 or phase 2? diff --git a/docs/use-cases/review-workflows.md b/docs/use-cases/review-workflows.md new file mode 100644 index 0000000..3a90ee0 --- /dev/null +++ b/docs/use-cases/review-workflows.md @@ -0,0 +1,90 @@ +# Review Workflows + +## Status + +Active + +## Use Case 1: Internal Review Before Client Review + +### Actors + +- Content contributor +- Provider +- Internal reviewer +- Manager + +### Scenario + +1. A contributor or provider creates or updates a draft. +2. The team links assets and updates the content item metadata. +3. An internal reviewer leaves comments or requests changes. +4. Revisions are linked or uploaded. +5. A manager decides the content item is ready for client review. + +### Outcome + +- the content item has an internal review history +- revisions are traceable +- the item advances to client review only after internal readiness + +## Use Case 2: Client Approval + +### Actors + +- Social media manager +- Client approver + +### Scenario + +1. The team sends a content item for client review. +2. The client reviews assets, caption/copy, dates, and notes. +3. The client records a decision: + - approve + - reject + - request changes +4. The team responds with comments or revisions when necessary. + +### Outcome + +- the decision is captured in the system +- the audit trail shows who decided what and when +- the team knows whether the item is approved, blocked, or requires changes + +## Use Case 3: Revision Loop + +### Actors + +- Provider or internal contributor +- Reviewer + +### Scenario + +1. A reviewer requests changes. +2. The owner of the work creates a revised asset or revised copy. +3. The new revision is linked to the content item. +4. The reviewer can compare current state against prior feedback context. + +### Outcome + +- the latest revision is identifiable +- older revisions remain traceable +- feedback does not get detached from the work item + +## Use Case 4: Ready For Publishing Handoff + +### Actors + +- Manager +- Publishing owner + +### Scenario + +1. All required review and approval work is complete. +2. The content item transitions to `Ready to publish`. +3. The downstream publishing owner uses the item as the approved handoff package. + +### Outcome + +- publishing handoff is based on an approved state +- the approved revision and metadata are clear +- the workflow history remains visible diff --git a/frontend/.env.development b/frontend/.env.development index bb4d8a1..d568be4 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,6 +1,4 @@ -VITE_API_URL=https://localhost:5001/ -VITE_APP_BASE_URL=http://localhost:5173 -VITE_APP_API_URL=http://localhost:5173/api +VITE_API_URL=http://192.168.1.2:5000 VITE_STRIPE_API_KEY=pk_test_51OoveVDrRyqXtNdB2st1NgA8WQA9rhgGaf3q7bCpAOoQyyRS30HMCzGeHba7meVGCSPfb1BVWmOTmFOcr9MkKf5H00bLu5MqsS VITE_GOOGLE_CLIENT_ID=213344094492-9dbaet2gaschju3hj1sgv1umk0qpd833.apps.googleusercontent.com -VITE_FACEBOOK_APP_ID \ No newline at end of file +VITE_FACEBOOK_APP_ID=1076433907621883 diff --git a/frontend/.env.production b/frontend/.env.production index 888b727..55a66b7 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,7 +1,6 @@ VITE_API_URL=https://hutopy-backend-api.azurewebsites.net -VITE_APP_BASE_URL=https://hutopy.ca -VITE_APP_API_URL=https://hutopy.ca/api VITE_STRIPE_API_KEY=51OoveVDrRyqXtNdBAxIo183PujtqFyU0xUMK9YNtIijcHeDlcLN6pqkZWHbgaBA0FHrwLMSoy3yVLN33NX8ExOxL00MSZwgJN7 VITE_GOOGLE_CLIENT_ID=213344094492-7c83lqoh7mnjgadpeqo2lcs1krhbsnnd.apps.googleusercontent.com +VITE_FACEBOOK_APP_ID=1076433907621883 AZURE_SUBSCRIPTION_ID=46feb20f-3ae1-495a-830b-a31f7b76483d -AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f \ No newline at end of file +AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f diff --git a/frontend/.gitignore b/frontend/.gitignore index fe7578f..8f1b6bf 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,7 +1,3 @@ -# Ignore cert localhost -localhost-key.pem -localhost.pem - # Environment files .env.local .env.*.local diff --git a/frontend/README.md b/frontend/README.md index b2501cb..642a16c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -7,13 +7,9 @@ Hutopia frontEnd. Using vue3 and vuetify3. ## System Setup -Setup SSL certificates for localhost on your machine. Use the following commands to generate and store the certificates. +Local frontend runtime configuration lives in `.env.development` for development and `.env.production` for production. Update those files when changing API endpoints or OAuth client ids. -```sh -openssl genrsa -out localhost-key.pem -openssl req -new -key localhost-key.pem -out csr.pem -openssl x509 -req -days 365 -in csr.pem -signkey localhost-key.pem -out localhost.pem -``` +The dev server runs over HTTP on `http://localhost:5173`. ## Recommended IDE Setup diff --git a/frontend/SSL-dev.md b/frontend/SSL-dev.md deleted file mode 100644 index ef8f29f..0000000 --- a/frontend/SSL-dev.md +++ /dev/null @@ -1,137 +0,0 @@ - -# Setting Up SSL for Local Development - -## Installing Chocolatey (Windows Only) - -### What is Chocolatey? -Chocolatey is a package manager for Windows, making it easy to install and manage software packages via the command line. - -### Steps to Install Chocolatey: - -1. **Open PowerShell as Administrator**: - - Press `Windows + X` and select **Windows PowerShell (Admin)** from the menu. - -2. **Run the Following Command to Install Chocolatey**: - - ```powershell - Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - ``` - -3. **Verify the Installation**: - Once the installation is complete, you can verify Chocolatey is installed by running the following command: - - ```powershell - choco --version - ``` - - If Chocolatey is installed correctly, it will output the current version number. - -### Common Chocolatey Commands - -- **Install a package**: - - ```powershell - choco install - ``` - -- **Uninstall a package**: - - ```powershell - choco uninstall - ``` - -- **Upgrade all installed packages**: - - ```powershell - choco upgrade all - ``` - -### Additional Notes - -- Chocolatey is required for installing `mkcert` and other software dependencies. -- Make sure to always run PowerShell as Administrator when using Chocolatey to install or manage packages. - ---- - -## Setting Up SSL for Local Development - -To ensure that your local development environment runs with SSL, follow these steps to generate a locally trusted SSL certificate using `mkcert`: - -### Requirements - -- Install [Node.js](https://nodejs.org) (which includes npm) -- Install [mkcert](https://github.com/FiloSottile/mkcert) - -### Steps - -1. **Install `mkcert`**: - - Install `mkcert` using Chocolatey on Windows (or use an appropriate package manager for other OS). - - For Windows users, open PowerShell as Administrator and run: - - ```powershell - choco install mkcert - ``` - - - For macOS users, run: - - ```bash - brew install mkcert - ``` - -2. **Install the Local Certificate Authority (CA)**: - After installing `mkcert`, run the following command to install the local CA: - - ```bash - mkcert -install - ``` - - This will set up a trusted local CA to issue certificates. - -3. **Generate SSL Certificates**: - In your project root (or a preferred directory), generate the SSL certificate and key for `localhost`: - - ```bash - mkcert localhost - ``` - - This will generate two files: - - `localhost.pem` (the certificate) - - `localhost-key.pem` (the private key) - -4. **Update the `vite.config.js`**: - Ensure your project is set up to use the generated certificate. Your `vite.config.js` should contain the following lines to enable SSL for the development server: - - ```javascript - import { defineConfig } from 'vite'; - import vue from '@vitejs/plugin-vue'; - import fs from 'fs'; - import path from 'path'; - - export default defineConfig({ - plugins: [vue()], - server: { - https: { - key: fs.readFileSync(path.resolve(__dirname, 'path_to_your_key/localhost-key.pem')), - cert: fs.readFileSync(path.resolve(__dirname, 'path_to_your_cert/localhost.pem')), - }, - host: 'localhost', - }, - }); - ``` - - Replace `path_to_your_key` and `path_to_your_cert` with the location of your generated certificate and key files. - -5. **Start the Development Server**: - Run the development server with SSL by using: - - ```bash - npm run dev - ``` - -6. **Access the Application**: - Open your browser and navigate to `https://localhost:3000` (or the port your Vite server is running on). - -### Additional Notes - -- If you encounter any issues related to SSL, ensure that the local CA is properly installed by running `mkcert -install` again. -- Each developer should follow these steps to generate their own trusted SSL certificate. Avoid committing the generated `.pem` and `.key` files to version control for security reasons. \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 26805cf..3623fec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - Hutopy + Socialize @@ -13,4 +13,4 @@ - \ No newline at end of file + diff --git a/frontend/localhost-key.pem b/frontend/localhost-key.pem deleted file mode 100644 index bedaf96..0000000 --- a/frontend/localhost-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC2322I83McDEox -Pfpq3bNTaza/DtOEa80QuSnhjK0yk+qUJKYJByfPWQz6GhPmI9YpIRKlZtB5aPs8 -4qTBgbY/w6DxX5tRXaCwlVe6DN/8aKBS4vcAEryfIqPdAUMqog9sp74AGDGEAzKn -28FduqX9FQ9TC+id5GY9NeiwTx2g1wM6Id5PycBZgCSxZcPEg5229doeg7LsmEQj -837ZwJ6Aavxnyn5bCyZh4wUmxb8lAyFZokd3szY+NLhw3OnKJZ0mYVxHvrRxCzlG -gaobDFqV3UWTq+a42S6WvS4JKtZq+LHAAy1kZpSY0tfFdUwTuT/lxevNy7VLFVup -gpWGcofpAgMBAAECggEAU5DuAPMe2uZS0QW9dTAyTiBkOBKSXaTVZJr4pHUggEhP -nbrRlLaMXpgW8gMQrM4bg1f1qVe+VHzAsiXvm+2mVqUS2roRw7DBSXA1UnOntzQo -bzgAHyxwvVebAdcd1lGQMtrEXE6x8d10PHiTeD1etLP2+MAsYFqKzdXgqxC8PU69 -I3ee/O6noGbpzddm5je93qoqncfIO6zd1PqYYkr93+9yffn+dbeOxWOnPHkZNiRB -tYd4D5Cfin2NL+8pQ5BBPdt8xktOFKEhFTOgazV9qwU6ZJAg+UbwUKP1mogpNMhQ -4Ci8T+RQmUfBIq07a1h6ksCTbtS4ByXs8HEA95+FwQKBgQDEeBQJkteDxthZFDMt -vO3TaHoFptKIYgJ6zxJR4ngo2UOc/cPM876qLNvw2p/6E2hnpGMYAZb6o9ipAQQR -WZm52F29X/rtmU2E/03QPE/fCdj3KfZUxtA+xbJ0ecqKgv99Hi0mkJrIUEWgO5n3 -0CG87J1dR1HUPqJjbDYkpmz9PQKBgQDuSLKvzipOxXpnJlV0x9PZXWzx+elCT1Hm -uK8j5bagb+MjQdh+u4g3Kgf2g73pl8w80kQp9vExJcmmfLNTcPpbeUL1BXF40NdE -AP/DChN+ynZCFpmE3zIOjy9B7txq+qnu10yrxF6vaUVbjoVZ3d6mZ0QQJ45exqUy -p4S1u864HQKBgHarVOcHc/dbhtgfVF5fDIOySmnZfrb0BC1rn9Qn54481RMhUEAe -Rd8CI4MSeqiRSnG3oEcixq/zgW1reKqGJU1UvCIjtCwJegJINxb9Jv1ANHXuOaSx -RZ10yjqCSe1p/Kn1LS5rD6LIoZWMCo7df1Ne1BpAdtOtVWaaOQXgJFq9AoGAJx5K -L3ByI6Jp2NtDNjvD/LBIvWTgtWEeOflhz0vb8nTL3jLmHtAcqam9yuuP1vRztBx0 -0krXB9GDTFC2g+FNSI0cv+rX2RS38lMTqepSjwMf7POW2mhl6Fv7TyCukOV71lkE -HkLLpJJsr34zSDCTZ9AWLWzBA7Aq2KkFsWwWoMUCgYBFYcc3qhMP08fsjSO0Tlu6 -Bqmxm1t/+1bGGOPtZOQNcKQRLJfa/c7Cp2F6vYK1yXeautJvoZxrbVptS39yGgMd -I90MkxH5XpFpJYd3tX9QP021AdmXRtk0KBIQobs/y2bnxiT+kcELEq17g1V7MAft -WI16saz8M5wPtM62SxKjxQ== ------END PRIVATE KEY----- diff --git a/frontend/localhost.pem b/frontend/localhost.pem deleted file mode 100644 index feaad13..0000000 --- a/frontend/localhost.pem +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIETDCCArSgAwIBAgIQBSpXJsGF49z8CNN9ViHXjTANBgkqhkiG9w0BAQsFADCB -iTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS8wLQYDVQQLDCZKTy1E -RVZcbG93cmFAam8tZGV2IChKb25hdGhhbiBCb3VyZG9uKTE2MDQGA1UEAwwtbWtj -ZXJ0IEpPLURFVlxsb3dyYUBqby1kZXYgKEpvbmF0aGFuIEJvdXJkb24pMB4XDTI0 -MTAwODAzNDIxNFoXDTI3MDEwODA0NDIxNFowWjEnMCUGA1UEChMebWtjZXJ0IGRl -dmVsb3BtZW50IGNlcnRpZmljYXRlMS8wLQYDVQQLDCZKTy1ERVZcbG93cmFAam8t -ZGV2IChKb25hdGhhbiBCb3VyZG9uKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBALbfbYjzcxwMSjE9+mrds1NrNr8O04RrzRC5KeGMrTKT6pQkpgkHJ89Z -DPoaE+Yj1ikhEqVm0Hlo+zzipMGBtj/DoPFfm1FdoLCVV7oM3/xooFLi9wASvJ8i -o90BQyqiD2ynvgAYMYQDMqfbwV26pf0VD1ML6J3kZj016LBPHaDXAzoh3k/JwFmA -JLFlw8SDnbb12h6DsuyYRCPzftnAnoBq/GfKflsLJmHjBSbFvyUDIVmiR3ezNj40 -uHDc6colnSZhXEe+tHELOUaBqhsMWpXdRZOr5rjZLpa9Lgkq1mr4scADLWRmlJjS -18V1TBO5P+XF683LtUsVW6mClYZyh+kCAwEAAaNeMFwwDgYDVR0PAQH/BAQDAgWg -MBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFMJlZrGHE5r1pdB1VMFr -fa/gSQhoMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEA -u+p2G45y2ReLLgC1oIDc3j/Gl+mXQ9AMgj/aIrRE/iZy6wedd2/2AEKpK9e9Dp/z -ckXZoB9D1gcfigQV+xwjAGO939eteDJiYlksB99ujKP5lP6wxJV2kWuQ5THB9b0x -P/td5U2/Va0wElRwg2q7k8+IYRye9A0dK+3ofiFQ3zThgmeq0tBC0DkhFqVVbdO1 -1uhgp2SyF7iHB6pIELBlWAXo50wFx+smshFUxX1FT7Y1SbvknZvqFWyyQvD4lymG -EOInURWL4MaMM+JuIbgOabVawaG6sBXhjHNsoYtm6ttbpaMNRpprJ/skY816FtMY -FLq5CkJep9qyNy0Y6N02pb6LPRgoSv9dgx3uY9iD4mgf2vUx9vZBvLvAzF1nQ+PE -WJMlcYry0jE0xy37jNLpUqYALf3gYVwYwnxqe0ytKwHrDXcdkDXROnMJNYsKjZbc -MO4yatpPclBgod6yF4RLc5B08f+jOWzqsHdUSUPITeVZSFaE5UjColhT3l9JxiCB ------END CERTIFICATE----- diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3ac1351..9c93647 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,53 +1,100 @@ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 9250a03..01ffef8 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -3,21 +3,19 @@ @tailwind utilities; :root { - /* Branding Colors */ - --hutopy-primary: #6B0065; - --hutopy-secondary: #A30E79; - - /* UI COLORS */ - --h-background: #1c181c; - --h-on-background: #e2e5e9; - --h-surface: #252225; - --h-on-surface: #e2e5e9; - --h-primary: #242b2b; - --h-on-primary: #e2e5e9; - --h-secondary: #e7e5ea; - --h-on-secondary: #000000; - --h-tertiary: #466568; - --h-on-tertiary: #bdb6b6; + --socialize-primary: #172033; + --socialize-accent: #ff8a3d; + --socialize-highlight: #2fa58d; + --h-background: #fffaf2; + --h-on-background: #172033; + --h-surface: #ffffff; + --h-on-surface: #172033; + --h-primary: #172033; + --h-on-primary: #fffaf2; + --h-secondary: #fff3e2; + --h-on-secondary: #172033; + --h-tertiary: #d9f6ee; + --h-on-tertiary: #0f766e; --h-error: #bc2f2f; --h-on-error: #ffffff; } diff --git a/frontend/src/components/AppAvatar.vue b/frontend/src/components/AppAvatar.vue new file mode 100644 index 0000000..ad755e6 --- /dev/null +++ b/frontend/src/components/AppAvatar.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/ImageCropperDialog.vue b/frontend/src/components/ImageCropperDialog.vue new file mode 100644 index 0000000..867c444 --- /dev/null +++ b/frontend/src/components/ImageCropperDialog.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/frontend/src/composables/useFacebookLogin.js b/frontend/src/composables/useFacebookLogin.js index ae5f179..a97a619 100644 --- a/frontend/src/composables/useFacebookLogin.js +++ b/frontend/src/composables/useFacebookLogin.js @@ -1,13 +1,11 @@ import {onMounted, ref} from "vue"; import {useHead} from "@vueuse/head"; import {useAuthStore} from "@/stores/authStore.js"; +import config from "@/config.js"; export function useFacebookLogin() { const isSdkLoaded = ref(false); - /* TODO: FIND THE ACTUAL HUTOPY'S APP_ID */ - const FACEBOOK_APP_ID = "1076433907621883"; - useHead({ script: [ { @@ -33,7 +31,7 @@ export function useFacebookLogin() { const initializeFacebookSDK = () => { window.fbAsyncInit = function () { FB.init({ - appId: FACEBOOK_APP_ID, + appId: config.facebookAppId, xfbml: true, version: 'v22.0' }); diff --git a/frontend/src/config.js b/frontend/src/config.js index 934032b..91ad6e1 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,7 +1,18 @@ -// Environment-specific configuration -const config = { - baseUrl: import.meta.env.VITE_APP_BASE_URL || 'https://hutopy.ca', - apiUrl: import.meta.env.VITE_APP_API_URL || 'https://hutopy.ca/api', -}; +function getRequiredEnv(name) { + const value = import.meta.env[name]; -export default config; \ No newline at end of file + if (!value) { + throw new Error(`${name} is not provided`); + } + + return value; +} + +const config = Object.freeze({ + apiUrl: getRequiredEnv('VITE_API_URL'), + googleClientId: getRequiredEnv('VITE_GOOGLE_CLIENT_ID'), + facebookAppId: getRequiredEnv('VITE_FACEBOOK_APP_ID'), + stripeApiKey: getRequiredEnv('VITE_STRIPE_API_KEY'), +}); + +export default config; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 607c05a..a3d7815 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -33,9 +33,437 @@ "x": "X (Twitter)", "youtube": "YouTube", "website": "Website", + "common": { + "cancel": "Cancel", + "creating": "Creating..." + }, + "workspaceSelector": { + "createAction": "Add workspace" + }, + "workspaceCreate": { + "eyebrow": "Workspace", + "title": "Create a new workspace", + "description": "Set up a new workspace with its own slug, timezone, members, workflow, and connectors.", + "previewTitle": "Workspace URL", + "previewDescription": "The slug becomes the stable identifier used for the workspace.", + "formTitle": "Workspace details", + "formDescription": "Start with the core fields now. Members, workflow, and connectors can be configured right after creation.", + "createAction": "Create workspace", + "slugHint": "Workspace slug preview: {slug}", + "fields": { + "name": "Workspace name", + "namePlaceholder": "Northwind Studio", + "slug": "Workspace slug", + "slugPlaceholder": "northwind-studio", + "timeZone": "Time zone" + }, + "errors": { + "required": "All workspace fields are required.", + "createFailed": "The workspace could not be created." + } + }, + "nav": { + "brandCaption": "Approval workflow", + "workspace": "Workspace", + "notifications": "Notifications", + "dashboard": "Dashboard", + "overview": "Overview", + "workspacePlan": "Content", + "mediaLibrary": "Media Library", + "channels": "Channels", + "projects": "Campaigns", + "reviewQueue": "Review Queue", + "content": "Content", + "profile": "Profile", + "signIn": "Sign in", + "settings": "Settings", + "language": "Language", + "signOut": "Sign out", + "noWorkspace": "No workspace" + }, + "notifications": { + "title": "Notifications", + "unread": "unread", + "loading": "Loading notifications...", + "empty": "No workflow notifications yet.", + "events": { + "approvalRequested": "Approval requested", + "approvalDecisionRecorded": "Approval decision recorded", + "commentCreated": "Comment added", + "commentResolved": "Comment resolved", + "contentCreated": "Content item created", + "revisionCreated": "Revision created", + "statusUpdated": "Status updated", + "assetLinked": "Asset linked", + "assetRevisionCreated": "Asset revision created" + } + }, + "sidebar": { + "allClients": "All clients", + "allChannels": "All channels", + "allProjects": "All campaigns", + "allReviewItems": "Full review queue", + "noClients": "No clients yet.", + "noChannels": "No channels yet.", + "noProjects": "No campaigns yet.", + "noReviewItems": "No review items right now." + }, + "settings": { + "eyebrow": "Settings", + "title": "Account settings", + "userInformation": "User information", + "workspaces": "Workspaces", + "integrations": "Integrations" + }, + "dashboard": { + "eyebrow": "Workspace schedule", + "title": "Schedule", + "description": "See what is scheduled for a given day and review the posting agenda in order.", + "workspaceLabel": "Active workspace", + "loading": "Loading workspace data...", + "calendarKicker": "Daily agenda", + "executionKicker": "Next up", + "riskKicker": "Delivery risk", + "reviewKicker": "Review pulse", + "upcomingContent": "Upcoming content", + "deliveryRisks": "What can slip", + "overdueItems": "Overdue items", + "approvalBlockers": "Awaiting approval or revisions", + "unscheduledProjects": "Campaigns without scheduled content", + "reviewQueueSnapshot": "Review queue snapshot", + "emptyUpcoming": "No upcoming scheduled content.", + "emptyOverdue": "Nothing overdue right now.", + "emptyApproval": "No approval blockers at the moment.", + "emptyProjects": "Every campaign has at least one scheduled content item.", + "emptyReviewQueue": "No active review queue items.", + "previousDay": "Previous day", + "nextDay": "Next day", + "today": "Today", + "month": "Month", + "week": "Week", + "campaignDeadline": "Campaign deadline", + "emptyPeriod": "No scheduled items.", + "daySummary": "{content} content items · {projects} campaign deadlines", + "moreItems": "+{count} more", + "emptyDayAgenda": "No content is scheduled for this day.", + "projectProgress": "{scheduled} scheduled · {approved} approved", + "missingSchedule": "Needs content scheduled", + "noDueDate": "No due date", + "labels": { + "unassignedProject": "Unassigned campaign" + }, + "readiness": { + "building": "In production", + "approval": "Awaiting approval", + "rework": "Needs revision", + "ready": "Ready to publish", + "published": "Published", + "blocked": "Blocked", + "archived": "Archived", + "scheduled": "Scheduled", + "missing": "No content scheduled" + }, + "stats": { + "scheduledThisDay": "Scheduled this day", + "overdue": "Overdue", + "awaitingApproval": "Awaiting approval", + "readyToShip": "Ready to ship" + } + }, + "overview": { + "eyebrow": "Portfolio overview", + "title": "Cross-workspace timeline", + "description": "See upcoming deliveries, risks, and activity across every workspace you can access.", + "loading": "Loading overview data...", + "workspacesKicker": "Access scope", + "workspaceRollup": "Workspace rollup", + "timelineKicker": "Upcoming", + "upcomingTitle": "Scheduled across workspaces", + "riskKicker": "Watch list", + "risksTitle": "Items already at risk", + "activityKicker": "Recent activity", + "activityTitle": "Latest workflow events", + "emptyUpcoming": "No upcoming scheduled items across your workspaces.", + "emptyRisks": "No cross-workspace delivery risks right now.", + "emptyActivity": "No recent workflow activity yet.", + "labels": { + "projects": "campaigns", + "upcoming": "upcoming", + "blocked": "blocked" + }, + "stats": { + "workspaces": "Workspaces", + "projects": "Campaigns", + "upcoming": "Upcoming items", + "blockers": "At-risk items" + } + }, + "clients": { + "eyebrow": "Client management", + "title": "Clients", + "description": "Client accounts, brand identity, and primary approval contacts.", + "newClient": "New client", + "createTitle": "Create client", + "loading": "Loading clients...", + "empty": "No clients are available for the active workspace.", + "noPrimaryContact": "No primary contact set", + "noPrimaryContactEmail": "No primary contact email set", + "errors": { + "nameRequired": "Client name is required.", + "createFailed": "The client could not be created." + }, + "fields": { + "name": "Client name", + "portraitUrl": "Client logo or portrait URL", + "primaryContactName": "Primary contact name", + "primaryContactEmail": "Primary contact email", + "primaryContactPortraitUrl": "Primary contact portrait URL" + } + }, + "projects": { + "eyebrow": "Campaign planning", + "title": "Campaigns", + "description": "Campaigns grouped inside the active workspace by status, date range, and planning notes.", + "newProject": "New campaign", + "createTitle": "Create campaign", + "loading": "Loading campaigns...", + "empty": "No campaigns are available for the active workspace.", + "unknownClient": "Unknown client", + "noDateRange": "No date range", + "errors": { + "required": "Campaign name and date range are required.", + "invalidDateRange": "The end date must be on or after the start date.", + "workspaceAccountRequired": "This workspace needs an operational account before campaigns can be created.", + "createFailed": "The campaign could not be created." + }, + "fields": { + "client": "Client", + "selectClient": "Select a client", + "startDate": "Start date", + "endDate": "End date", + "name": "Campaign name", + "description": "Description", + "notes": "Notes" + } + }, + "channels": { + "title": "Channels", + "description": "Add channels to the workspace.", + "createTitle": "Create channel", + "empty": "No channels are available for the active workspace yet.", + "emptyAction": "Add a channel for {network}", + "nextDue": "Next due", + "noScheduled": "Nothing scheduled", + "fields": { + "name": "Channel name", + "network": "Network" + }, + "metrics": { + "scheduled": "Scheduled", + "ready": "Ready", + "blocked": "Blocked" + }, + "errors": { + "createFailed": "The channel could not be created." + } + }, + "reviewQueue": { + "eyebrow": "Review workflow", + "title": "Review queue", + "description": "Pending approvals, revisions, and change requests for the active workspace.", + "empty": "No review items are available for the active workspace." + }, + "contentItems": { + "eyebrow": "Content workflow", + "title": "Content items", + "description": "Reviewable units with assets, copy, and approval status inside the active workspace.", + "newItem": "New content item", + "createTitle": "Create content item", + "loading": "Loading content items...", + "empty": "No content items are available for the active workspace.", + "noDueDate": "No due date", + "assetsHelper": "Google Drive assets are now linked from the content item detail page after creation.", + "errors": { + "required": "Title, campaign, message, and targets are required.", + "workspaceAccountRequired": "This workspace needs an operational account before content can be created.", + "createFailed": "The content item could not be created." + }, + "fields": { + "title": "Title", + "client": "Client", + "selectClient": "Select a client", + "project": "Campaign", + "selectProject": "Select a campaign", + "dueDate": "Due date", + "publicationTargets": "Publication targets", + "publicationTargetsPlaceholder": "Instagram Reel, TikTok", + "publicationMessage": "Publication message", + "hashtags": "Hashtags", + "hashtagsPlaceholder": "#launch #brand #campaign", + "assets": "Assets" + } + }, + "userSettings": { + "eyebrow": "User information", + "title": "Profile and identity", + "description": "Manage the portrait and account details shown inside the workspace.", + "updatePortrait": "Update portrait", + "accountDetails": "Account details", + "accountDetailsDescription": "Additional account editing fields can be added here next.", + "alias": "Alias", + "fullName": "Full name", + "email": "Email", + "noEmail": "No email set", + "cropperTitle": "Update user portrait", + "savePortrait": "Save portrait", + "choosePortrait": "Choose portrait" + }, + "workspaceSettings": { + "eyebrow": "Settings", + "title": "Workspace settings", + "description": "Configure the current workspace across general details, members, workflow, and connectors.", + "currentWorkspace": "Current workspace", + "noWorkspaceSelected": "No workspace selected", + "activeWorkspace": "Active workspace", + "contextNote": "These settings apply to the current workspace only.", + "inviteTitle": "Invite workspace members", + "inviteDescription": "Invite clients, subcontractors, or teammates into the active workspace.", + "inviteEmpty": "No pending invites for this workspace yet.", + "sendInvite": "Send invite", + "reset": "Reset", + "errors": { + "required": "All workspace fields are required.", + "createFailed": "The workspace could not be created.", + "inviteRequired": "Email and role are required to invite a member.", + "inviteFailed": "The workspace invite could not be created." + }, + "fields": { + "name": "Workspace name", + "slug": "Workspace slug", + "timeZone": "Time zone", + "memberEmail": "Member email", + "memberRole": "Role" + }, + "roles": { + "administrator": "Administrator", + "manager": "Manager", + "client": "Client reviewer", + "provider": "Subcontractor", + "workspaceMember": "Workspace member" + }, + "summary": { + "name": "Name", + "slug": "Slug", + "timeZone": "Time zone", + "created": "Created" + }, + "tabs": { + "general": "General", + "members": "Members", + "workflow": "Workflow", + "connectors": "Connectors" + }, + "members": { + "inviteTitle": "Invite", + "activeTitle": "Members", + "activeDescription": "See everyone who currently belongs to the active workspace.", + "activeEmpty": "No members found for this workspace.", + "pendingTitle": "Pending invitations", + "pendingDescription": "Track who has been invited into the active workspace." + }, + "connectors": { + "title": "Connectors", + "description": "Manage workspace-level connectors that feed operational features like the media library.", + "openMediaLibrary": "Open media library", + "googleDrive": { + "title": "Google Drive", + "description": "This connector should power the workspace media sync for images, videos, and other shared files.", + "status": "Pending setup" + } + }, + "general": { + "summaryTitle": "Workspace summary", + "summaryDescription": "Reference details for the workspace currently in context." + }, + "approvals": { + "flowTitle": "Approval flow", + "flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.", + "previewTitle": "Flow preview", + "previewDescription": "This is the sequence the workspace will use based on the current configuration.", + "saved": "Approval flow saved for this workspace in this browser.", + "fields": { + "requireInternalReview": "Require internal review", + "internalApproversRequired": "Internal approvers required", + "requireClientReview": "Require client review", + "clientApproversRequired": "Client approvers required", + "defaultReviewerRole": "Default reviewer role", + "publishBehaviour": "After final approval" + }, + "fieldHelp": { + "requireInternalReview": "Content must be approved internally before client review can begin.", + "requireClientReview": "Content must still pass through client approval before publication." + }, + "publishBehaviour": { + "manual": "Mark ready to publish", + "auto": "Auto-advance to ready" + }, + "steps": { + "internal": "Internal review", + "client": "Client review", + "publish": "Publishing handoff" + }, + "stepDetail": { + "approverCount": "{count} approver(s) required", + "autoPublish": "Content moves to ready automatically after the final approval.", + "manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval." + } + } + }, + "integrations": { + "eyebrow": "Integrations", + "title": "Google Drive and API keys", + "description": "This is where workspace-level integrations and credential configuration should live.", + "statusLabel": "Status", + "pendingTitle": "Configuration UI pending", + "googleDrive": { + "title": "Google Drive", + "description": "Configure the workspace connection used for asset linking and revision intake.", + "nextStep": "Next step: add stored workspace integration settings and connect them to the asset-link flow." + }, + "apiKeys": { + "title": "API keys", + "description": "Workspace-scoped secrets and external service credentials should be managed here.", + "nextStep": "Next step: add secure backend persistence and masked key management." + } + }, + "mediaLibrary": { + "eyebrow": "Media library", + "title": "Workspace media", + "description": "Manage the shared image and video library that should sync with Google Drive for this workspace.", + "syncCard": { + "title": "Google Drive sync", + "description": "Use this area to connect the workspace drive, pull approved media in, and keep the library aligned with external folders." + }, + "mediaTypesTitle": "Supported media", + "mediaTypesDescription": "The library should become the single place to browse visual assets before they are linked into content work.", + "mediaTypes": { + "images": "Images, graphics, and brand visuals", + "videos": "Videos, reels, and motion exports" + }, + "workflowTitle": "Planned workflow", + "workflowDescription": "This page is the intended home for the Google Drive sync flow we discussed.", + "workflow": { + "connectDrive": "Connect the workspace Google Drive source.", + "syncAssets": "Sync image and video assets into the internal library.", + "organizeLibrary": "Review, tag, and reuse media from one workspace-level place." + }, + "statusLabel": "Status", + "pendingTitle": "Management UI pending", + "pendingDescription": "The navigation and page entry point are in place. Next step is wiring actual Drive sync, listing, filters, and asset actions." + }, "errors": { "unexpected": "An unexpected error occurred", "imageLoad": "Error loading image", "imageUpload": "Error uploading image" } -} \ No newline at end of file +} diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 697f101..86c066f 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -33,9 +33,437 @@ "x": "X (Twitter)", "youtube": "YouTube", "website": "Site web", + "common": { + "cancel": "Annuler", + "creating": "Création..." + }, + "workspaceSelector": { + "createAction": "Ajouter un espace" + }, + "workspaceCreate": { + "eyebrow": "Espace", + "title": "Creer un nouvel espace", + "description": "Configurez un nouvel espace avec son slug, son fuseau horaire, ses membres, son workflow et ses connecteurs.", + "previewTitle": "URL de l'espace", + "previewDescription": "Le slug devient l'identifiant stable utilise pour l'espace.", + "formTitle": "Details de l'espace", + "formDescription": "Commencez par les champs essentiels. Les membres, le workflow et les connecteurs peuvent etre configures juste apres la creation.", + "createAction": "Creer l'espace", + "slugHint": "Apercu du slug : {slug}", + "fields": { + "name": "Nom de l'espace", + "namePlaceholder": "Northwind Studio", + "slug": "Slug de l'espace", + "slugPlaceholder": "northwind-studio", + "timeZone": "Fuseau horaire" + }, + "errors": { + "required": "Tous les champs de l'espace sont requis.", + "createFailed": "L'espace n'a pas pu etre cree." + } + }, + "nav": { + "brandCaption": "Flux d'approbation", + "workspace": "Espace de travail", + "notifications": "Notifications", + "dashboard": "Tableau de bord", + "overview": "Vue globale", + "workspacePlan": "Contenu", + "mediaLibrary": "Bibliotheque media", + "channels": "Canaux", + "projects": "Campagnes", + "reviewQueue": "File de révision", + "content": "Contenu", + "profile": "Profil", + "signIn": "Se connecter", + "settings": "Paramètres", + "language": "Langue", + "signOut": "Se déconnecter", + "noWorkspace": "Aucun espace" + }, + "notifications": { + "title": "Notifications", + "unread": "non lues", + "loading": "Chargement des notifications...", + "empty": "Aucune notification de workflow pour le moment.", + "events": { + "approvalRequested": "Approbation demandée", + "approvalDecisionRecorded": "Décision d'approbation enregistrée", + "commentCreated": "Commentaire ajouté", + "commentResolved": "Commentaire résolu", + "contentCreated": "Élément de contenu créé", + "revisionCreated": "Révision créée", + "statusUpdated": "Statut mis à jour", + "assetLinked": "Ressource liée", + "assetRevisionCreated": "Révision de ressource créée" + } + }, + "sidebar": { + "allClients": "Tous les clients", + "allChannels": "Tous les canaux", + "allProjects": "Toutes les campagnes", + "allReviewItems": "File de révision complète", + "noClients": "Aucun client pour le moment.", + "noChannels": "Aucun canal pour le moment.", + "noProjects": "Aucune campagne pour le moment.", + "noReviewItems": "Aucun élément à réviser pour le moment." + }, + "settings": { + "eyebrow": "Paramètres", + "title": "Paramètres du compte", + "userInformation": "Informations utilisateur", + "workspaces": "Espaces de travail", + "integrations": "Intégrations" + }, + "dashboard": { + "eyebrow": "Calendrier de l'espace", + "title": "Calendrier", + "description": "Voyez ce qui est prévu pour une journée donnée et consultez l'agenda des publications dans l'ordre.", + "workspaceLabel": "Espace actif", + "loading": "Chargement des données de l'espace...", + "calendarKicker": "Agenda du jour", + "executionKicker": "À venir", + "riskKicker": "Risque de livraison", + "reviewKicker": "État des révisions", + "upcomingContent": "Contenu à venir", + "deliveryRisks": "Ce qui peut glisser", + "overdueItems": "Éléments en retard", + "approvalBlockers": "En attente d'approbation ou de révision", + "unscheduledProjects": "Campagnes sans contenu planifié", + "reviewQueueSnapshot": "Aperçu de la file de révision", + "emptyUpcoming": "Aucun contenu planifié à venir.", + "emptyOverdue": "Rien n'est en retard pour le moment.", + "emptyApproval": "Aucun blocage d'approbation pour le moment.", + "emptyProjects": "Chaque campagne a au moins un élément de contenu planifié.", + "emptyReviewQueue": "Aucun élément actif dans la file de révision.", + "previousDay": "Jour précédent", + "nextDay": "Jour suivant", + "today": "Aujourd'hui", + "month": "Mois", + "week": "Semaine", + "campaignDeadline": "Échéance de campagne", + "emptyPeriod": "Aucun élément planifié.", + "daySummary": "{content} contenus · {projects} échéances de campagne", + "moreItems": "+{count} autres", + "emptyDayAgenda": "Aucun contenu n'est planifié pour cette journée.", + "projectProgress": "{scheduled} planifiés · {approved} approuvés", + "missingSchedule": "Contenu à planifier", + "noDueDate": "Aucune échéance", + "labels": { + "unassignedProject": "Campagne non attribuée" + }, + "readiness": { + "building": "En production", + "approval": "En attente d'approbation", + "rework": "Révision requise", + "ready": "Prêt à publier", + "published": "Publié", + "blocked": "Bloqué", + "archived": "Archivé", + "scheduled": "Planifié", + "missing": "Aucun contenu planifié" + }, + "stats": { + "scheduledThisDay": "Planifiés ce jour", + "overdue": "En retard", + "awaitingApproval": "En attente d'approbation", + "readyToShip": "Prêts à livrer" + } + }, + "overview": { + "eyebrow": "Vue portefeuille", + "title": "Chronologie multi-espaces", + "description": "Suivez les livraisons à venir, les risques et l'activité sur tous les espaces auxquels vous avez accès.", + "loading": "Chargement des données globales...", + "workspacesKicker": "Périmètre d'accès", + "workspaceRollup": "Synthèse des espaces", + "timelineKicker": "À venir", + "upcomingTitle": "Planifié sur tous les espaces", + "riskKicker": "À surveiller", + "risksTitle": "Éléments déjà à risque", + "activityKicker": "Activité récente", + "activityTitle": "Derniers événements du workflow", + "emptyUpcoming": "Aucun élément planifié à venir sur vos espaces.", + "emptyRisks": "Aucun risque de livraison multi-espace pour le moment.", + "emptyActivity": "Aucune activité récente du workflow.", + "labels": { + "projects": "campagnes", + "upcoming": "à venir", + "blocked": "bloqués" + }, + "stats": { + "workspaces": "Espaces", + "projects": "Campagnes", + "upcoming": "Éléments à venir", + "blockers": "Éléments à risque" + } + }, + "clients": { + "eyebrow": "Gestion client", + "title": "Clients", + "description": "Comptes clients, identité de marque et contacts principaux d'approbation.", + "newClient": "Nouveau client", + "createTitle": "Créer un client", + "loading": "Chargement des clients...", + "empty": "Aucun client n'est disponible pour l'espace actif.", + "noPrimaryContact": "Aucun contact principal défini", + "noPrimaryContactEmail": "Aucun email de contact principal défini", + "errors": { + "nameRequired": "Le nom du client est requis.", + "createFailed": "Le client n'a pas pu être créé." + }, + "fields": { + "name": "Nom du client", + "portraitUrl": "URL du logo ou portrait du client", + "primaryContactName": "Nom du contact principal", + "primaryContactEmail": "Email du contact principal", + "primaryContactPortraitUrl": "URL du portrait du contact principal" + } + }, + "projects": { + "eyebrow": "Planification des campagnes", + "title": "Campagnes", + "description": "Campagnes regroupées dans l'espace actif par statut, plage de dates et notes de planification.", + "newProject": "Nouvelle campagne", + "createTitle": "Créer une campagne", + "loading": "Chargement des campagnes...", + "empty": "Aucune campagne n'est disponible pour l'espace actif.", + "unknownClient": "Client inconnu", + "noDateRange": "Aucune plage de dates", + "errors": { + "required": "Le nom de la campagne et la plage de dates sont requis.", + "invalidDateRange": "La date de fin doit être postérieure ou égale à la date de début.", + "workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer des campagnes.", + "createFailed": "La campagne n'a pas pu être créée." + }, + "fields": { + "client": "Client", + "selectClient": "Sélectionner un client", + "startDate": "Date de début", + "endDate": "Date de fin", + "name": "Nom de la campagne", + "description": "Description", + "notes": "Notes" + } + }, + "channels": { + "title": "Canaux", + "description": "Ajoutez des canaux à l'espace.", + "createTitle": "Créer un canal", + "empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.", + "emptyAction": "Ajouter un canal pour {network}", + "nextDue": "Prochaine échéance", + "noScheduled": "Rien de planifié", + "fields": { + "name": "Nom du canal", + "network": "Réseau" + }, + "metrics": { + "scheduled": "Planifié", + "ready": "Prêt", + "blocked": "Bloqué" + }, + "errors": { + "createFailed": "Le canal n'a pas pu être créé." + } + }, + "reviewQueue": { + "eyebrow": "Flux de révision", + "title": "File de révision", + "description": "Approbations, révisions et demandes de changement en attente pour l'espace actif.", + "empty": "Aucun élément de révision n'est disponible pour l'espace actif." + }, + "contentItems": { + "eyebrow": "Flux de contenu", + "title": "Éléments de contenu", + "description": "Unités révisables avec ressources, texte et statut d'approbation dans l'espace actif.", + "newItem": "Nouvel élément de contenu", + "createTitle": "Créer un élément de contenu", + "loading": "Chargement des éléments de contenu...", + "empty": "Aucun élément de contenu n'est disponible pour l'espace actif.", + "noDueDate": "Aucune échéance", + "assetsHelper": "Les ressources Google Drive sont maintenant liées depuis la page de détail de l'élément après sa création.", + "errors": { + "required": "Le titre, la campagne, le message et les cibles sont requis.", + "workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer du contenu.", + "createFailed": "L'élément de contenu n'a pas pu être créé." + }, + "fields": { + "title": "Titre", + "client": "Client", + "selectClient": "Sélectionner un client", + "project": "Campagne", + "selectProject": "Sélectionner une campagne", + "dueDate": "Date d'échéance", + "publicationTargets": "Cibles de publication", + "publicationTargetsPlaceholder": "Instagram Reel, TikTok", + "publicationMessage": "Message de publication", + "hashtags": "Hashtags", + "hashtagsPlaceholder": "#lancement #marque #campagne", + "assets": "Ressources" + } + }, + "userSettings": { + "eyebrow": "Informations utilisateur", + "title": "Profil et identité", + "description": "Gérez le portrait et les informations du compte affichés dans l'espace.", + "updatePortrait": "Mettre à jour le portrait", + "accountDetails": "Détails du compte", + "accountDetailsDescription": "Des champs supplémentaires d'édition du compte peuvent être ajoutés ici ensuite.", + "alias": "Alias", + "fullName": "Nom complet", + "email": "Email", + "noEmail": "Aucun email défini", + "cropperTitle": "Mettre à jour le portrait utilisateur", + "savePortrait": "Enregistrer le portrait", + "choosePortrait": "Choisir un portrait" + }, + "workspaceSettings": { + "eyebrow": "Paramètres", + "title": "Paramètres de l'espace", + "description": "Configurez l'espace courant avec les sections general, membres, workflow et connecteurs.", + "currentWorkspace": "Espace actuel", + "noWorkspaceSelected": "Aucun espace sélectionné", + "activeWorkspace": "Espace actif", + "contextNote": "Ces paramètres s'appliquent uniquement à l'espace courant.", + "inviteTitle": "Inviter des membres", + "inviteDescription": "Invitez des clients, sous-traitants ou collègues dans l'espace actif.", + "inviteEmpty": "Aucune invitation en attente pour cet espace.", + "sendInvite": "Envoyer l'invitation", + "reset": "Réinitialiser", + "errors": { + "required": "Tous les champs de l'espace sont requis.", + "createFailed": "L'espace n'a pas pu être créé.", + "inviteRequired": "L'email et le rôle sont requis pour inviter un membre.", + "inviteFailed": "L'invitation de l'espace n'a pas pu être créée." + }, + "fields": { + "name": "Nom de l'espace", + "slug": "Slug de l'espace", + "timeZone": "Fuseau horaire", + "memberEmail": "Email du membre", + "memberRole": "Rôle" + }, + "roles": { + "administrator": "Administrateur", + "manager": "Gestionnaire", + "client": "Réviseur client", + "provider": "Sous-traitant", + "workspaceMember": "Membre de l'espace" + }, + "summary": { + "name": "Nom", + "slug": "Slug", + "timeZone": "Fuseau horaire", + "created": "Créé" + }, + "tabs": { + "general": "Général", + "members": "Membres", + "workflow": "Workflow", + "connectors": "Connecteurs" + }, + "members": { + "inviteTitle": "Inviter", + "activeTitle": "Membres", + "activeDescription": "Voyez toutes les personnes qui appartiennent actuellement à l'espace actif.", + "activeEmpty": "Aucun membre trouvé pour cet espace.", + "pendingTitle": "Invitations en attente", + "pendingDescription": "Suivez les personnes invitées dans l'espace actif." + }, + "connectors": { + "title": "Connecteurs", + "description": "Gerez les connecteurs au niveau de l'espace qui alimentent des fonctions comme la bibliotheque media.", + "openMediaLibrary": "Ouvrir la bibliotheque media", + "googleDrive": { + "title": "Google Drive", + "description": "Ce connecteur doit alimenter la synchronisation media de l'espace pour les images, videos et autres fichiers partages.", + "status": "Configuration en attente" + } + }, + "general": { + "summaryTitle": "Résumé de l'espace", + "summaryDescription": "Détails de référence pour l'espace actuellement en contexte." + }, + "approvals": { + "flowTitle": "Flux d'approbation", + "flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.", + "previewTitle": "Aperçu du flux", + "previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.", + "saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.", + "fields": { + "requireInternalReview": "Exiger une révision interne", + "internalApproversRequired": "Approbateurs internes requis", + "requireClientReview": "Exiger une révision client", + "clientApproversRequired": "Approbateurs client requis", + "defaultReviewerRole": "Rôle du réviseur par défaut", + "publishBehaviour": "Après l'approbation finale" + }, + "fieldHelp": { + "requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.", + "requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication." + }, + "publishBehaviour": { + "manual": "Marquer prêt à publier", + "auto": "Passer automatiquement à prêt" + }, + "steps": { + "internal": "Révision interne", + "client": "Révision client", + "publish": "Passage à la publication" + }, + "stepDetail": { + "approverCount": "{count} approbateur(s) requis", + "autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.", + "manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale." + } + } + }, + "integrations": { + "eyebrow": "Intégrations", + "title": "Google Drive et clés API", + "description": "C'est ici que doivent vivre les intégrations au niveau de l'espace et la configuration des identifiants.", + "statusLabel": "Statut", + "pendingTitle": "Interface de configuration en attente", + "googleDrive": { + "title": "Google Drive", + "description": "Configurez la connexion de l'espace utilisée pour la liaison des ressources et l'entrée des révisions.", + "nextStep": "Prochaine étape : ajouter des paramètres d'intégration stockés pour l'espace et les connecter au flux de liaison des ressources." + }, + "apiKeys": { + "title": "Clés API", + "description": "Les secrets de l'espace et identifiants de services externes doivent être gérés ici.", + "nextStep": "Prochaine étape : ajouter une persistance backend sécurisée et une gestion masquée des clés." + } + }, + "mediaLibrary": { + "eyebrow": "Bibliotheque media", + "title": "Medias de l'espace", + "description": "Gerez la bibliotheque partagee d'images et de videos qui devrait se synchroniser avec Google Drive pour cet espace.", + "syncCard": { + "title": "Synchronisation Google Drive", + "description": "Cette zone servira a connecter le Drive de l'espace, importer les medias approuves et garder la bibliotheque alignee sur les dossiers externes." + }, + "mediaTypesTitle": "Medias pris en charge", + "mediaTypesDescription": "La bibliotheque doit devenir l'endroit unique pour parcourir les ressources visuelles avant de les lier au contenu.", + "mediaTypes": { + "images": "Images, visuels graphiques et elements de marque", + "videos": "Videos, reels et exports en mouvement" + }, + "workflowTitle": "Flux prevu", + "workflowDescription": "Cette page est le point d'entree prevu pour le flux de synchronisation Google Drive dont on a parle.", + "workflow": { + "connectDrive": "Connecter la source Google Drive de l'espace.", + "syncAssets": "Synchroniser les images et videos dans la bibliotheque interne.", + "organizeLibrary": "Reviser, etiqueter et reutiliser les medias depuis un seul endroit au niveau de l'espace." + }, + "statusLabel": "Statut", + "pendingTitle": "Interface de gestion en attente", + "pendingDescription": "L'entree de navigation et la page sont en place. La prochaine etape est de brancher la vraie synchro Drive, le listing, les filtres et les actions sur les ressources." + }, "errors": { "unexpected": "Une erreur inattendue s'est produite", "imageLoad": "Erreur lors du chargement de l'image", "imageUpload": "Erreur lors du téléchargement de l'image" } -} \ No newline at end of file +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 52698cc..2e498a0 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -21,13 +21,18 @@ import { import vueGoogleOauth from 'vue3-google-login'; import { useAuthStore } from '@/stores/authStore.js'; import { useUserProfileStore } from '@/stores/userProfileStore.js'; -import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js'; import Toast, { POSITION } from 'vue-toastification'; import 'vue-toastification/dist/index.css'; import './assets/main.css'; -import { createI18n } from 'vue-i18n'; -import en from '@/locales/en.json'; -import fr from '@/locales/fr.json'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useReviewQueueStore } from '@/stores/reviewQueueStore.js'; +import { useContentItemsStore } from '@/stores/contentItemsStore.js'; +import { useClientsStore } from '@/stores/clientsStore.js'; +import { useProjectsStore } from '@/stores/projectsStore.js'; +import { useNotificationsStore } from '@/stores/notificationsStore.js'; +import { useChannelsStore } from '@/stores/channelsStore.js'; +import { i18n } from '@/plugins/i18n.js'; +import config from '@/config.js'; const vuetify = createVuetify({ components: { @@ -51,15 +56,6 @@ const vuetify = createVuetify({ }, }); -const i18n = createI18n({ - legacy: false, - fallbackLocale: 'fr', - messages: { - en: en, - fr: fr, - }, -}); - const pinia = createPinia(); const app = createApp(App) @@ -68,7 +64,7 @@ const app = createApp(App) .use(router) .use(i18n) .use(vueGoogleOauth, { - clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID, + clientId: config.googleClientId, }) .use(Toast, { position: POSITION.TOP_CENTER, @@ -76,6 +72,12 @@ const app = createApp(App) useAuthStore(); useUserProfileStore(); -useCreatorProfileStore(); +useWorkspaceStore(); +useClientsStore(); +useProjectsStore(); +useChannelsStore(); +useReviewQueueStore(); +useContentItemsStore(); +useNotificationsStore(); app.mount('#app'); diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js index e0c564f..bd35458 100644 --- a/frontend/src/plugins/api.js +++ b/frontend/src/plugins/api.js @@ -1,13 +1,10 @@ import axios from 'axios'; import { useAuthStore } from '@/stores/authStore.js'; +import config from '@/config.js'; export function useClient() { - if (!import.meta.env.VITE_API_URL) { - throw new Error('VITE_API_URL is not provided'); - } - const client = axios.create({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: config.apiUrl, headers: { 'Content-Type': 'application/json' } diff --git a/frontend/src/plugins/i18n.js b/frontend/src/plugins/i18n.js new file mode 100644 index 0000000..d3d2805 --- /dev/null +++ b/frontend/src/plugins/i18n.js @@ -0,0 +1,12 @@ +import { createI18n } from 'vue-i18n'; +import en from '@/locales/en.json'; +import fr from '@/locales/fr.json'; + +export const i18n = createI18n({ + legacy: false, + fallbackLocale: 'en', + messages: { + en, + fr, + }, +}); diff --git a/frontend/src/router/router.js b/frontend/src/router/router.js index 4b121c2..0d6de7a 100644 --- a/frontend/src/router/router.js +++ b/frontend/src/router/router.js @@ -1,115 +1,147 @@ import { useAuthStore } from '@/stores/authStore.js'; import { createRouter, createWebHistory } from 'vue-router'; -import CreatorHome from '@/views/creators/CreatorHome.vue'; -import CreatorLayout from '@/views/creators/CreatorLayout.vue'; - const LoginView = () => import('@/views/auth/LoginView.vue'); - -const About = () => import('@/views/documentation/About.vue'); -const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue'); -const CreatorGuide = () => import('@/views/documentation/CreatorGuide.vue'); -const DocumentationLayout = () => import('@/views/documentation/DocumentationLayout.vue'); -const FAQ = () => import('@/views/documentation/FAQ.vue'); -const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue'); -const Pricing = () => import('@/views/documentation/Pricing.vue'); -const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue'); -const ProfilePage = () => import('@/views/profile/ProfilePage.vue'); -const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue'); -const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue'); const Landing = () => import('@/views/main/Landing.vue'); - -const CreateCreator = () => import('@/views/creators/CreateCreator.vue'); const RegisterView = () => import('@/views/auth/RegisterView.vue'); const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue'); const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue'); const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue'); +const OverviewView = () => import('@/views/app/OverviewView.vue'); +const DashboardView = () => import('@/views/app/DashboardView.vue'); +const ChannelsView = () => import('@/views/app/ChannelsView.vue'); +const CampaignsView = () => import('@/views/app/ProjectsView.vue'); +const CampaignDetailView = () => import('@/views/app/ProjectDetailView.vue'); +const MediaLibraryView = () => import('@/views/app/MediaLibraryView.vue'); +const WorkspaceCreateView = () => import('@/views/app/WorkspaceCreateView.vue'); +const SettingsLayoutView = () => import('@/views/app/SettingsLayoutView.vue'); +const UserSettingsView = () => import('@/views/app/UserSettingsView.vue'); +const IntegrationsSettingsView = () => import('@/views/app/IntegrationsSettingsView.vue'); +const WorkspaceSettingsView = () => import('@/views/app/WorkspaceSettingsView.vue'); +const ReviewQueueView = () => import('@/views/app/ReviewQueueView.vue'); +const ContentItemsView = () => import('@/views/app/ContentItemsView.vue'); +const ContentItemDetailView = () => import('@/views/app/ContentItemDetailView.vue'); const routes = [ { - path: '/landing', + path: '/', name: 'landing', component: Landing, }, { - path: '/', - redirect: { name: 'landing' }, + path: '/app', + redirect: { name: 'dashboard' }, }, { - path: '/@:creator', - component: CreatorLayout, + path: '/app/dashboard', + name: 'dashboard', + component: OverviewView, + meta: { requiresAuth: true }, + }, + { + path: '/app/workspace', + name: 'workspace-dashboard', + component: DashboardView, + meta: { requiresAuth: true }, + }, + { + path: '/app/channels', + name: 'channels', + component: ChannelsView, + meta: { requiresAuth: true }, + }, + { + path: '/app/media-library', + name: 'media-library', + component: MediaLibraryView, + meta: { requiresAuth: true }, + }, + { + path: '/app/campaigns', + name: 'campaigns', + component: CampaignsView, + meta: { requiresAuth: true }, + }, + { + path: '/app/campaigns/:projectId', + name: 'campaign-detail', + component: CampaignDetailView, + meta: { requiresAuth: true }, + }, + { + path: '/app/reviews', + name: 'review-queue', + component: ReviewQueueView, + meta: { requiresAuth: true }, + }, + { + path: '/app/workspace-settings', + name: 'workspace-settings', + component: WorkspaceSettingsView, + meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] }, + }, + { + path: '/app/workspaces/new', + name: 'workspace-create', + component: WorkspaceCreateView, + meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] }, + }, + { + path: '/app/settings', + component: SettingsLayoutView, + meta: { requiresAuth: true }, children: [ { path: '', - name: 'creator', - component: CreatorHome, + redirect: { name: 'settings-user-information' }, }, { - path: 'tip-completed', - name: 'PaymentCompleted', - component: PaymentCompleted, + path: 'user-information', + name: 'settings-user-information', + component: UserSettingsView, }, { - path: 'tip-cancelled', - name: 'PaymentFailed', - component: PaymentFailed, + path: 'workspaces', + name: 'settings-workspaces', + component: WorkspaceSettingsView, + meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] }, + }, + { + path: 'integrations', + name: 'settings-integrations', + component: IntegrationsSettingsView, + meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] }, }, ], }, { - path: '/documents', - component: DocumentationLayout, - children: [ - { - path: 'helpandcontact', - name: 'helpandcontact', - component: HelpAndContact, - }, - { - path: 'termsandconditions', - name: 'termsandconditions', - component: TermsAndConditions, - }, - { - path: 'contentpolicy', - name: 'contentpolicy', - component: ContentPolicy, - }, - { - path: 'faq', - name: 'FAQ', - component: FAQ, - }, - { - path: 'about', - name: 'about', - component: About, - }, - { - path: 'pricing', - name: 'pricing', - component: Pricing, - }, - ], + path: '/app/content', + name: 'content-items', + component: ContentItemsView, + meta: { requiresAuth: true }, + }, + { + path: '/app/content/new', + name: 'content-item-create', + component: ContentItemDetailView, + meta: { requiresAuth: true }, + }, + { + path: '/app/content/:id', + name: 'content-item-detail', + component: ContentItemDetailView, + meta: { requiresAuth: true }, }, { path: '/login', name: 'login', component: LoginView, meta: { notAuthenticated: true }, - props: route => ({ returnUrl: route.query.returnUrl || '/landing' }), + props: route => ({ returnUrl: route.query.returnUrl || '/app/dashboard' }), }, { path: '/profile', - name: 'profile', - component: ProfilePage, - meta: { requiresAuth: true }, - }, - { - path: '/create-creator', - name: 'create-creator', - component: CreateCreator, - meta: { requiresAuth: true }, + redirect: { name: 'dashboard' }, }, { path: '/register', @@ -136,6 +168,10 @@ const routes = [ component: VerifyEmailView, meta: { notAuthenticated: true }, }, + { + path: '/:pathMatch(.*)*', + redirect: { name: 'landing' }, + }, ]; const router = createRouter({ @@ -154,10 +190,16 @@ router.beforeEach((to, from, next) => { query: { returnUrl: to.fullPath }, }); } else { + const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []); + if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) { + next({ name: 'dashboard' }); + return; + } + next(); } } else if (to.matched.some(record => record.meta.notAuthenticated)) { - if (authStore.isAuthenticated) next({ name: 'landing' }); + if (authStore.isAuthenticated) next({ name: 'dashboard' }); else next(); } else { next(); diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index b1ad7eb..5abada1 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -24,6 +24,20 @@ export const useAuthStore = defineStore('auth', () => { const isAuthenticated = computed(() => !!accessToken.value); const userId = computed(() => tokenClaims.value?.sub); + const userRoles = computed(() => { + const claims = tokenClaims.value ?? {}; + const candidates = [ + claims.role, + claims.roles, + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'], + ].flatMap(value => Array.isArray(value) ? value : value ? [value] : []); + + return [...new Set(candidates)]; + }); + const persona = computed(() => tokenClaims.value?.persona ?? null); + const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager')); + const isClient = computed(() => userRoles.value.includes('Client')); + const isProvider = computed(() => userRoles.value.includes('Provider')); function updateTokens(data) { if (!data?.accessToken || !data?.refreshToken) { @@ -259,11 +273,21 @@ export const useAuthStore = defineStore('auth', () => { } } + function hasAnyRole(roles) { + return roles.some(role => userRoles.value.includes(role)); + } + return { accessToken, refreshToken, isAuthenticated, userId, + userRoles, + persona, + hasAnyRole, + isManager, + isClient, + isProvider, isRefreshing, login, loginWithGoogle, diff --git a/frontend/src/stores/brandingStore.js b/frontend/src/stores/brandingStore.js deleted file mode 100644 index 9e71f9f..0000000 --- a/frontend/src/stores/brandingStore.js +++ /dev/null @@ -1,96 +0,0 @@ -import {defineStore} from 'pinia' -import {useClient} from "@/plugins/api.js"; -import {useSessionStorage} from "@vueuse/core"; -import {ref, watch} from "vue"; -import {useRoute, useRouter} from "vue-router"; - -export const useBrandingStore = defineStore( - 'branding', - () => { - - const currentBrand = ref(undefined) - const loading = ref(false) - const error = ref(null) - const notFound = ref(false) - - const value = useSessionStorage( - 'branding', - {}, - {writeDefaults: false}) - - const presentationInfos = ref([]) - const router = useRouter() - const route = useRoute() - watch( - () => route.params.creator, - async (creator) => { - // Extract just the creator name from the path (remove any additional segments) - const creatorName = creator ? creator.split('/')[0] : undefined; - await updateBrand(creatorName); - } - ) - - async function updateBrand(newBrand) { - loading.value = true - error.value = null - notFound.value = false - - if (newBrand !== currentBrand.value) { - if (newBrand !== undefined) { - const result = await fetchCreatorData(newBrand) - if (result.success) { - value.value = result.data - currentBrand.value = newBrand - presentationInfos.value = result.data?.presentationInfos - } else { - // Handle different error types - if (result.status === 404) { - notFound.value = true - error.value = 'Creator not found' - } else { - error.value = result.error || 'Failed to load creator' - } - value.value = {} - currentBrand.value = undefined - presentationInfos.value = [] - } - } else { - value.value = {} - currentBrand.value = undefined - presentationInfos.value = [] - } - } - - loading.value = false - } - - const fetchCreatorData = async (creatorAlias) => { - try { - const client = useClient() - const response = await client.get(`/api/creators/@${creatorAlias}`) - return { success: true, data: response.data } - } catch (error) { - console.error('Error fetching creator data:', error) - - if (error.response?.status === 404) { - return { success: false, status: 404, error: 'Creator not found' } - } - - return { - success: false, - status: error.response?.status || 500, - error: error.message || 'Unknown error occurred' - } - } - } - - return { - currentBrand, - value, - loading, - error, - notFound, - updateBrand, - presentationInfos - } - }) diff --git a/frontend/src/stores/channelsStore.js b/frontend/src/stores/channelsStore.js new file mode 100644 index 0000000..622572c --- /dev/null +++ b/frontend/src/stores/channelsStore.js @@ -0,0 +1,122 @@ +import { computed } from 'vue'; +import { defineStore } from 'pinia'; +import { useSessionStorage } from '@vueuse/core'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useContentItemsStore } from '@/stores/contentItemsStore.js'; + +export const useChannelsStore = defineStore('channels', () => { + const workspaceStore = useWorkspaceStore(); + const contentItemsStore = useContentItemsStore(); + const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, { + serializer: { + read: value => (value ? JSON.parse(value) : {}), + write: value => JSON.stringify(value ?? {}), + }, + }); + + const channels = computed(() => { + const currentWorkspaceId = workspaceStore.activeWorkspaceId; + + if (!currentWorkspaceId) { + return []; + } + + const derivedChannels = new Map(); + const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? []; + + for (const item of contentItemsStore.items) { + for (const name of parseTargets(item.publicationTargets)) { + const key = slugify(name); + const existing = derivedChannels.get(key) ?? { + id: key, + name, + network: null, + source: 'derived', + }; + + derivedChannels.set(key, existing); + } + } + + for (const channel of customChannels) { + derivedChannels.set(channel.id, { + ...channel, + source: 'custom', + }); + } + + return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name)); + }); + + const availableNetworks = [ + 'Instagram', + 'TikTok', + 'Facebook', + 'LinkedIn', + 'YouTube', + 'X', + 'Reddit', + 'Website', + ]; + + function createChannel(payload) { + const currentWorkspaceId = workspaceStore.activeWorkspaceId; + + if (!currentWorkspaceId) { + throw new Error('An active workspace is required to create a channel.'); + } + + const normalizedName = payload.name.trim(); + const normalizedNetwork = payload.network.trim(); + + if (!normalizedName) { + throw new Error('Channel name is required.'); + } + + if (!normalizedNetwork) { + throw new Error('Network is required.'); + } + + if (!availableNetworks.includes(normalizedNetwork)) { + throw new Error('Selected network is invalid.'); + } + + const existing = channels.value.some(channel => + channel.name.toLowerCase() === normalizedName.toLowerCase() + && (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase() + ); + if (existing) { + throw new Error('A channel with this name already exists for the selected network.'); + } + + const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? []; + customChannelsByWorkspace.value = { + ...customChannelsByWorkspace.value, + [currentWorkspaceId]: [ + ...next, + { + id: slugify(`${normalizedNetwork}-${normalizedName}`), + name: normalizedName, + network: normalizedNetwork, + }, + ], + }; + } + + function parseTargets(value) { + return (value ?? '') + .split(/[,\n]+/) + .map(target => target.trim()) + .filter(Boolean); + } + + function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + } + + return { + availableNetworks, + channels, + createChannel, + }; +}); diff --git a/frontend/src/stores/clientsStore.js b/frontend/src/stores/clientsStore.js new file mode 100644 index 0000000..e3277f5 --- /dev/null +++ b/frontend/src/stores/clientsStore.js @@ -0,0 +1,182 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/stores/authStore.js'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useClientsStore = defineStore('clients', () => { + const authStore = useAuthStore(); + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const clients = ref([]); + const isLoading = ref(false); + const isCreating = ref(false); + const isUpdating = ref(false); + const isUploadingPortrait = ref(false); + const error = ref(null); + const operationalClient = computed(() => { + if (!clients.value.length) { + return null; + } + + return clients.value.find(candidate => candidate.name === workspaceStore.activeWorkspace?.name) + ?? clients.value[0]; + }); + + async function fetchClients() { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + clients.value = []; + error.value = null; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/clients', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + }, + }); + + clients.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch clients:', fetchError); + clients.value = []; + error.value = 'Failed to load clients.'; + } finally { + isLoading.value = false; + } + } + + async function createClient(payload) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + throw new Error('You must be authenticated to create a client.'); + } + + if (isCreating.value) { + throw new Error('A client creation request is already in progress.'); + } + + isCreating.value = true; + error.value = null; + + try { + const response = await client.post('/api/clients', { + ...payload, + workspaceId: workspaceStore.activeWorkspaceId, + }); + + if (response.data) { + clients.value = [...clients.value, response.data] + .sort((left, right) => left.name.localeCompare(right.name)); + } + + return response.data; + } catch (createError) { + console.error('Failed to create client:', createError); + error.value = 'Failed to create client.'; + throw createError; + } finally { + isCreating.value = false; + } + } + + async function updateClient(clientId, payload) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + throw new Error('You must be authenticated to update a client.'); + } + + if (isUpdating.value) { + throw new Error('A client update request is already in progress.'); + } + + isUpdating.value = true; + error.value = null; + + try { + const response = await client.put(`/api/clients/${clientId}`, payload); + + if (response.data) { + clients.value = clients.value + .map(candidate => candidate.id === clientId ? response.data : candidate) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + return response.data; + } catch (updateError) { + console.error('Failed to update client:', updateError); + error.value = 'Failed to update client.'; + throw updateError; + } finally { + isUpdating.value = false; + } + } + + async function uploadClientPortrait(clientId, file) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + throw new Error('You must be authenticated to upload a client logo.'); + } + + if (isUploadingPortrait.value) { + throw new Error('A client logo upload is already in progress.'); + } + + isUploadingPortrait.value = true; + error.value = null; + + try { + const formData = new FormData(); + formData.append('file', file, file.name || 'client-logo.png'); + + const response = await client.post(`/api/clients/${clientId}/portrait`, formData); + const blobUrl = response.data?.blobUrl; + + if (blobUrl) { + clients.value = clients.value.map(candidate => + candidate.id === clientId + ? { ...candidate, portraitUrl: `${blobUrl}?${Date.now()}` } + : candidate + ); + } + + return response.data; + } catch (uploadError) { + console.error('Failed to upload client logo:', uploadError); + error.value = 'Failed to upload client logo.'; + throw uploadError; + } finally { + isUploadingPortrait.value = false; + } + } + + watch( + () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], + async ([isAuthenticated, workspaceId]) => { + if (!isAuthenticated || !workspaceId) { + clients.value = []; + error.value = null; + return; + } + + await fetchClients(); + }, + { immediate: true } + ); + + return { + clients, + operationalClient, + isLoading, + isCreating, + isUpdating, + isUploadingPortrait, + error, + fetchClients, + createClient, + updateClient, + uploadClientPortrait, + }; +}); diff --git a/frontend/src/stores/contentItemDetailStore.js b/frontend/src/stores/contentItemDetailStore.js new file mode 100644 index 0000000..6ce7122 --- /dev/null +++ b/frontend/src/stores/contentItemDetailStore.js @@ -0,0 +1,255 @@ +import { reactive, ref } from 'vue'; +import { defineStore } from 'pinia'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useContentItemDetailStore = defineStore('content-item-detail', () => { + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const item = ref(null); + const revisions = ref([]); + const assets = ref([]); + const comments = ref([]); + const approvals = ref([]); + const notifications = ref([]); + const isLoading = ref(false); + const error = ref(null); + const actions = reactive({ + revision: false, + asset: false, + assetRevision: false, + comment: false, + approval: false, + decision: false, + status: false, + }); + + function reset() { + item.value = null; + revisions.value = []; + assets.value = []; + comments.value = []; + approvals.value = []; + notifications.value = []; + error.value = null; + } + + async function fetchContentItemDetail(contentItemId) { + isLoading.value = true; + error.value = null; + + try { + const [ + itemResponse, + revisionsResponse, + assetsResponse, + commentsResponse, + approvalsResponse, + notificationsResponse, + ] = await Promise.all([ + client.get(`/api/content-items/${contentItemId}`), + client.get(`/api/content-items/${contentItemId}/revisions`), + client.get('/api/assets', { params: { contentItemId } }), + client.get('/api/comments', { params: { contentItemId } }), + client.get('/api/approvals', { params: { contentItemId } }), + client.get('/api/notifications', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + contentItemId, + }, + }), + ]); + + item.value = itemResponse.data; + revisions.value = revisionsResponse.data ?? []; + assets.value = assetsResponse.data ?? []; + comments.value = commentsResponse.data ?? []; + approvals.value = approvalsResponse.data ?? []; + notifications.value = notificationsResponse.data ?? []; + } catch (fetchError) { + console.error('Failed to load content item detail:', fetchError); + reset(); + error.value = 'Failed to load the content item detail.'; + } finally { + isLoading.value = false; + } + } + + async function createRevision(contentItemId, payload) { + actions.revision = true; + + try { + const response = await client.post(`/api/content-items/${contentItemId}/revisions`, payload); + if (response.data) { + revisions.value = [response.data, ...revisions.value]; + await fetchContentItemDetail(contentItemId); + } + return response.data; + } finally { + actions.revision = false; + } + } + + async function addGoogleDriveAsset(contentItemId, payload) { + actions.asset = true; + + try { + const response = await client.post('/api/assets/google-drive', { + ...payload, + contentItemId, + workspaceId: workspaceStore.activeWorkspaceId, + }); + if (response.data) { + assets.value = [...assets.value, response.data]; + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.asset = false; + } + } + + async function addAssetRevision(contentItemId, assetId, payload) { + actions.assetRevision = true; + + try { + const response = await client.post(`/api/assets/${assetId}/revisions`, payload); + if (response.data) { + await fetchAssets(contentItemId); + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.assetRevision = false; + } + } + + async function addComment(contentItemId, payload) { + actions.comment = true; + + try { + const response = await client.post('/api/comments', { + ...payload, + contentItemId, + workspaceId: workspaceStore.activeWorkspaceId, + }); + if (response.data) { + comments.value = [...comments.value, response.data]; + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.comment = false; + } + } + + async function resolveComment(contentItemId, commentId) { + actions.comment = true; + + try { + const response = await client.post(`/api/comments/${commentId}/resolve`); + if (response.data) { + comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment); + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.comment = false; + } + } + + async function createApproval(contentItemId, payload) { + actions.approval = true; + + try { + const response = await client.post('/api/approvals', { + ...payload, + contentItemId, + workspaceId: workspaceStore.activeWorkspaceId, + }); + if (response.data) { + approvals.value = [response.data, ...approvals.value]; + await fetchContentItem(contentItemId); + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.approval = false; + } + } + + async function submitDecision(contentItemId, approvalId, payload) { + actions.decision = true; + + try { + const response = await client.post(`/api/approvals/${approvalId}/decisions`, payload); + if (response.data) { + approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval); + await fetchContentItem(contentItemId); + await fetchNotifications(contentItemId); + } + return response.data; + } finally { + actions.decision = false; + } + } + + async function updateStatus(contentItemId, status) { + actions.status = true; + + try { + const response = await client.post(`/api/content-items/${contentItemId}/status`, { status }); + item.value = response.data; + await fetchNotifications(contentItemId); + return response.data; + } finally { + actions.status = false; + } + } + + async function fetchContentItem(contentItemId) { + const response = await client.get(`/api/content-items/${contentItemId}`); + item.value = response.data; + return response.data; + } + + async function fetchAssets(contentItemId) { + const response = await client.get('/api/assets', { params: { contentItemId } }); + assets.value = response.data ?? []; + return assets.value; + } + + async function fetchNotifications(contentItemId) { + const response = await client.get('/api/notifications', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + contentItemId, + }, + }); + notifications.value = response.data ?? []; + return notifications.value; + } + + return { + item, + revisions, + assets, + comments, + approvals, + notifications, + isLoading, + error, + actions, + reset, + fetchContentItemDetail, + createRevision, + addGoogleDriveAsset, + addAssetRevision, + addComment, + resolveComment, + createApproval, + submitDecision, + updateStatus, + }; +}); diff --git a/frontend/src/stores/contentItemsStore.js b/frontend/src/stores/contentItemsStore.js new file mode 100644 index 0000000..0222821 --- /dev/null +++ b/frontend/src/stores/contentItemsStore.js @@ -0,0 +1,112 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/stores/authStore.js'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useContentItemsStore = defineStore('content-items', () => { + const authStore = useAuthStore(); + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const items = ref([]); + const isLoading = ref(false); + const isCreating = ref(false); + const error = ref(null); + + const activeCount = computed(() => + items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived') + .length + ); + + async function fetchContentItems(filters = {}) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + items.value = []; + error.value = null; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/content-items', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + clientId: filters.clientId, + projectId: filters.projectId, + }, + }); + + items.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch content items:', fetchError); + items.value = []; + error.value = 'Failed to load content items.'; + } finally { + isLoading.value = false; + } + } + + async function createContentItem(payload) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + throw new Error('You must be authenticated to create a content item.'); + } + + if (isCreating.value) { + throw new Error('A content item creation request is already in progress.'); + } + + isCreating.value = true; + error.value = null; + + try { + const response = await client.post('/api/content-items', { + ...payload, + workspaceId: workspaceStore.activeWorkspaceId, + }); + + if (response.data) { + items.value = [response.data, ...items.value]; + } + + return response.data; + } catch (createError) { + console.error('Failed to create content item:', createError); + error.value = 'Failed to create content item.'; + throw createError; + } finally { + isCreating.value = false; + } + } + + async function fetchContentItem(id) { + const response = await client.get(`/api/content-items/${id}`); + return response.data; + } + + watch( + () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], + async ([isAuthenticated, workspaceId]) => { + if (!isAuthenticated || !workspaceId) { + items.value = []; + error.value = null; + return; + } + + await fetchContentItems(); + }, + { immediate: true } + ); + + return { + items, + isLoading, + isCreating, + error, + activeCount, + fetchContentItems, + fetchContentItem, + createContentItem, + }; +}); diff --git a/frontend/src/stores/creatorProfileStore.js b/frontend/src/stores/creatorProfileStore.js deleted file mode 100644 index fc74a2a..0000000 --- a/frontend/src/stores/creatorProfileStore.js +++ /dev/null @@ -1,86 +0,0 @@ -import {useClient} from '@/plugins/api.js'; -import {useAuthStore} from '@/stores/authStore.js'; -import {useSessionStorage} from '@vueuse/core'; -import {defineStore} from 'pinia'; -import {computed, watch} from 'vue'; -import {useRouter} from 'vue-router'; - -export const useCreatorProfileStore = defineStore( - 'creator-profile', - () => { - - const router = useRouter(); - const authStore = useAuthStore(); - - watch( - () => authStore.isAuthenticated, - async (newValue) => { - if (newValue) { - await fetchCreatorProfile(); - if (value.value && value.value.name !== undefined) { - await router.push(`/@${value.value.slug}`); - } else { - await router.push('/'); - } - } else if (!authStore.isRefreshing) { - value.value = undefined; - } - } - ); - - const value = useSessionStorage( - 'creator-profile', - {}, - { - writeDefaults: false, - storage: window.sessionStorage, - serializer: { - read: (value) => value ? JSON.parse(value) : undefined, - write: (value) => value ? JSON.stringify(value) : undefined - } - } - ); - - const hasCreator = computed( - () => value.value && Object.getOwnPropertyNames(value.value).length >= 1 - ); - - const client = useClient(); - - async function fetchCreatorProfile() { - try { - const response = await client.get(`/api/creators/profile`); - value.value = response.data; - } catch (error) { - value.value = undefined; - } - } - - async function removeCreatorPage() { - try { - await client.delete(`/api/creators/@${value.value.slug}`) - await fetchCreatorProfile(); - } - catch(error) { - console.error(error); - } - } - - async function restoreCreatorPage() { - try { - await client.put(`/api/creators/@${value.value.slug}/restore`, {}) - await fetchCreatorProfile(); - } - catch(error) { - console.error(error); - } - } - - return { - creator: value, - hasCreator, - removeCreatorPage, - restoreCreatorPage, - fetchCreatorProfile - }; - }); diff --git a/frontend/src/stores/languageStore.js b/frontend/src/stores/languageStore.js index 783ab83..5930460 100644 --- a/frontend/src/stores/languageStore.js +++ b/frontend/src/stores/languageStore.js @@ -1,15 +1,13 @@ import { defineStore } from 'pinia'; import { useSessionStorage } from '@vueuse/core'; -import { useI18n } from 'vue-i18n'; +import { i18n } from '@/plugins/i18n.js'; const ALLOWED_LOCALES = ['en', 'fr']; -const DEFAULT_LOCALE = 'fr'; +const DEFAULT_LOCALE = 'en'; export const useLanguageStore = defineStore('language', () => { const storedLocale = useSessionStorage('user-locale', DEFAULT_LOCALE); - - // Get i18n instance (provided globally) - const { locale } = useI18n(); + const locale = i18n.global.locale; function sanitizeLocale(value) { return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE; @@ -18,15 +16,11 @@ export const useLanguageStore = defineStore('language', () => { // Initialize locale with a sanitized value const initial = sanitizeLocale(storedLocale.value); storedLocale.value = initial; - if (locale) { - locale.value = initial; - } + locale.value = initial; function setLocale(newLocale) { const next = sanitizeLocale(newLocale); - if (locale) { - locale.value = next; - } + locale.value = next; storedLocale.value = next; } diff --git a/frontend/src/stores/notificationsStore.js b/frontend/src/stores/notificationsStore.js new file mode 100644 index 0000000..fae926a --- /dev/null +++ b/frontend/src/stores/notificationsStore.js @@ -0,0 +1,89 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/stores/authStore.js'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useNotificationsStore = defineStore('notifications', () => { + const authStore = useAuthStore(); + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const items = ref([]); + const isLoading = ref(false); + const error = ref(null); + + const unreadCount = computed(() => + items.value.filter(item => !item.readAt).length + ); + + const recentItems = computed(() => items.value.slice(0, 6)); + + function reset() { + items.value = []; + error.value = null; + } + + async function fetchNotifications() { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + reset(); + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/notifications', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + }, + }); + + items.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch notifications:', fetchError); + items.value = []; + error.value = 'Failed to load notifications.'; + } finally { + isLoading.value = false; + } + } + + async function markAsRead(notificationId) { + try { + await client.post(`/api/notifications/${notificationId}/read`); + items.value = items.value.map(item => + item.id === notificationId + ? { ...item, readAt: item.readAt ?? new Date().toISOString() } + : item + ); + } catch (markError) { + console.error('Failed to mark notification as read:', markError); + } + } + + watch( + () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], + async ([isAuthenticated, workspaceId]) => { + if (!isAuthenticated || !workspaceId) { + reset(); + return; + } + + await fetchNotifications(); + }, + { immediate: true } + ); + + return { + items, + recentItems, + unreadCount, + isLoading, + error, + reset, + fetchNotifications, + markAsRead, + }; +}); diff --git a/frontend/src/stores/projectsStore.js b/frontend/src/stores/projectsStore.js new file mode 100644 index 0000000..4b4add5 --- /dev/null +++ b/frontend/src/stores/projectsStore.js @@ -0,0 +1,99 @@ +import { ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/stores/authStore.js'; +import { useWorkspaceStore } from '@/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useProjectsStore = defineStore('projects', () => { + const authStore = useAuthStore(); + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const projects = ref([]); + const isLoading = ref(false); + const isCreating = ref(false); + const error = ref(null); + + async function fetchProjects() { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + projects.value = []; + error.value = null; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/projects', { + params: { + workspaceId: workspaceStore.activeWorkspaceId, + }, + }); + + projects.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch projects:', fetchError); + projects.value = []; + error.value = 'Failed to load projects.'; + } finally { + isLoading.value = false; + } + } + + async function createProject(payload) { + if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { + throw new Error('You must be authenticated to create a project.'); + } + + if (isCreating.value) { + throw new Error('A project creation request is already in progress.'); + } + + isCreating.value = true; + error.value = null; + + try { + const response = await client.post('/api/projects', { + ...payload, + workspaceId: workspaceStore.activeWorkspaceId, + }); + + if (response.data) { + projects.value = [...projects.value, response.data] + .sort((left, right) => left.name.localeCompare(right.name)); + } + + return response.data; + } catch (createError) { + console.error('Failed to create project:', createError); + error.value = 'Failed to create project.'; + throw createError; + } finally { + isCreating.value = false; + } + } + + watch( + () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], + async ([isAuthenticated, workspaceId]) => { + if (!isAuthenticated || !workspaceId) { + projects.value = []; + error.value = null; + return; + } + + await fetchProjects(); + }, + { immediate: true } + ); + + return { + projects, + isLoading, + isCreating, + error, + fetchProjects, + createProject, + }; +}); diff --git a/frontend/src/stores/reviewQueueStore.js b/frontend/src/stores/reviewQueueStore.js new file mode 100644 index 0000000..4d0639b --- /dev/null +++ b/frontend/src/stores/reviewQueueStore.js @@ -0,0 +1,49 @@ +import { computed } from 'vue'; +import { defineStore } from 'pinia'; +import { useContentItemsStore } from '@/stores/contentItemsStore.js'; +import { useProjectsStore } from '@/stores/projectsStore.js'; + +const stageByStatus = { + Draft: 'Draft', + 'In internal review': 'Internal review', + 'Changes requested internally': 'Internal changes requested', + 'Internal changes in progress': 'Internal revision', + 'Ready for client review': 'Ready for client review', + 'In client review': 'Client review', + 'Changes requested by client': 'Client changes requested', + 'Client changes in progress': 'Client revision', + Approved: 'Approved', + Rejected: 'Rejected', + 'Ready to publish': 'Ready to publish', + Published: 'Published', + Archived: 'Archived', +}; + +export const useReviewQueueStore = defineStore('review-queue', () => { + const contentItemsStore = useContentItemsStore(); + const projectsStore = useProjectsStore(); + + const items = computed(() => + contentItemsStore.items + .filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived') + .map(item => { + const project = projectsStore.projects.find(candidate => candidate.id === item.projectId); + + return { + id: item.id, + title: item.title, + projectName: project?.name ?? 'Unknown campaign', + stage: stageByStatus[item.status] ?? item.status, + status: item.status, + dueLabel: item.dueDate ? `Due ${new Date(item.dueDate).toLocaleDateString()}` : 'No due date', + }; + }) + ); + + const urgentItems = computed(() => items.value.slice(0, 5)); + + return { + items, + urgentItems, + }; +}); diff --git a/frontend/src/stores/userProfileStore.js b/frontend/src/stores/userProfileStore.js index ab119a8..db511c2 100644 --- a/frontend/src/stores/userProfileStore.js +++ b/frontend/src/stores/userProfileStore.js @@ -50,9 +50,15 @@ export const useUserProfileStore = defineStore( const portraitUrl = computed(() => { return value.value && value.value.portraitUrl ? value.value.portraitUrl - : '/images/usersmedia/anonyme/profilepictures/profileAnonymeSquare.png' + : null }) + const roles = computed(() => value.value?.userRoles ?? []) + const persona = computed(() => value.value?.persona ?? null) + const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? []) + const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? []) + const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? []) + async function fetchCurrentUserProfile() { try { const client = useClient() @@ -153,7 +159,7 @@ export const useUserProfileStore = defineStore( try { const client = useClient() const formData = new FormData(); - formData.append('file', selectedFile) + formData.append('file', selectedFile, selectedFile.name || 'portrait.png') const response = await client.post( `/api/users/portrait`, @@ -170,6 +176,11 @@ export const useUserProfileStore = defineStore( alias, fullname, portraitUrl, + roles, + persona, + authorizedWorkspaceIds, + authorizedClientIds, + authorizedProjectIds, changeFullname, changeAlias, changeBirthday, diff --git a/frontend/src/stores/workspaceStore.js b/frontend/src/stores/workspaceStore.js new file mode 100644 index 0000000..e3006d4 --- /dev/null +++ b/frontend/src/stores/workspaceStore.js @@ -0,0 +1,208 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/stores/authStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useWorkspaceStore = defineStore('workspace', () => { + const authStore = useAuthStore(); + const client = useClient(); + + const workspaces = ref([]); + const activeWorkspaceId = ref(null); + const isLoading = ref(false); + const isCreating = ref(false); + const invitesByWorkspace = ref({}); + const membersByWorkspace = ref({}); + const isInvitesLoading = ref(false); + const isMembersLoading = ref(false); + const isInviting = ref(false); + const error = ref(null); + + const activeWorkspace = computed(() => + workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null + ); + + async function fetchWorkspaces() { + if (!authStore.isAuthenticated) { + workspaces.value = []; + activeWorkspaceId.value = null; + error.value = null; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/workspaces'); + workspaces.value = response.data ?? []; + + if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) { + activeWorkspaceId.value = workspaces.value[0]?.id ?? null; + } + } catch (fetchError) { + console.error('Failed to fetch workspaces:', fetchError); + workspaces.value = []; + activeWorkspaceId.value = null; + error.value = 'Failed to load workspaces.'; + } finally { + isLoading.value = false; + } + } + + async function createWorkspace(payload) { + if (!authStore.isAuthenticated) { + throw new Error('You must be authenticated to create a workspace.'); + } + + if (isCreating.value) { + throw new Error('A workspace creation request is already in progress.'); + } + + isCreating.value = true; + error.value = null; + + try { + const response = await client.post('/api/workspaces', payload); + + if (response.data) { + workspaces.value = [...workspaces.value, response.data] + .sort((left, right) => left.name.localeCompare(right.name)); + activeWorkspaceId.value = response.data.id; + + try { + await client.post('/api/clients', { + workspaceId: response.data.id, + name: response.data.name, + }); + } catch (hiddenClientError) { + console.error('Failed to provision operational client for workspace:', hiddenClientError); + } + } + + return response.data; + } catch (createError) { + console.error('Failed to create workspace:', createError); + error.value = 'Failed to create workspace.'; + throw createError; + } finally { + isCreating.value = false; + } + } + + function setActiveWorkspace(workspaceId) { + if (workspaces.value.some(workspace => workspace.id === workspaceId)) { + activeWorkspaceId.value = workspaceId; + } + } + + async function fetchInvites(workspaceId = activeWorkspaceId.value) { + if (!authStore.isAuthenticated || !workspaceId) { + invitesByWorkspace.value = {}; + return []; + } + + isInvitesLoading.value = true; + + try { + const response = await client.get(`/api/workspaces/${workspaceId}/invites`); + invitesByWorkspace.value = { + ...invitesByWorkspace.value, + [workspaceId]: response.data ?? [], + }; + + return invitesByWorkspace.value[workspaceId]; + } catch (fetchError) { + console.error('Failed to fetch workspace invites:', fetchError); + throw fetchError; + } finally { + isInvitesLoading.value = false; + } + } + + async function fetchMembers(workspaceId = activeWorkspaceId.value) { + if (!authStore.isAuthenticated || !workspaceId) { + membersByWorkspace.value = {}; + return []; + } + + isMembersLoading.value = true; + + try { + const response = await client.get(`/api/workspaces/${workspaceId}/members`); + membersByWorkspace.value = { + ...membersByWorkspace.value, + [workspaceId]: response.data ?? [], + }; + + return membersByWorkspace.value[workspaceId]; + } catch (fetchError) { + console.error('Failed to fetch workspace members:', fetchError); + throw fetchError; + } finally { + isMembersLoading.value = false; + } + } + + async function inviteMember(payload) { + if (!authStore.isAuthenticated || !activeWorkspaceId.value) { + throw new Error('You must be authenticated to invite a workspace member.'); + } + + if (isInviting.value) { + throw new Error('A workspace invite request is already in progress.'); + } + + isInviting.value = true; + + try { + const response = await client.post(`/api/workspaces/${activeWorkspaceId.value}/invites`, payload); + invitesByWorkspace.value = { + ...invitesByWorkspace.value, + [activeWorkspaceId.value]: [response.data, ...(invitesByWorkspace.value[activeWorkspaceId.value] ?? [])], + }; + + return response.data; + } catch (inviteError) { + console.error('Failed to create workspace invite:', inviteError); + throw inviteError; + } finally { + isInviting.value = false; + } + } + + watch( + () => authStore.isAuthenticated, + async isAuthenticated => { + if (!isAuthenticated) { + workspaces.value = []; + activeWorkspaceId.value = null; + error.value = null; + return; + } + + await fetchWorkspaces(); + }, + { immediate: true } + ); + + return { + workspaces, + activeWorkspaceId, + activeWorkspace, + isLoading, + isCreating, + invitesByWorkspace, + membersByWorkspace, + isInvitesLoading, + isMembersLoading, + isInviting, + error, + fetchWorkspaces, + createWorkspace, + fetchInvites, + fetchMembers, + inviteMember, + setActiveWorkspace, + }; +}); diff --git a/frontend/src/views/app/ChannelsView.vue b/frontend/src/views/app/ChannelsView.vue new file mode 100644 index 0000000..c02eced --- /dev/null +++ b/frontend/src/views/app/ChannelsView.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/views/app/ClientDetailView.vue b/frontend/src/views/app/ClientDetailView.vue new file mode 100644 index 0000000..df73f33 --- /dev/null +++ b/frontend/src/views/app/ClientDetailView.vue @@ -0,0 +1,712 @@ + + + + + diff --git a/frontend/src/views/app/ClientsView.vue b/frontend/src/views/app/ClientsView.vue new file mode 100644 index 0000000..f9798d3 --- /dev/null +++ b/frontend/src/views/app/ClientsView.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/frontend/src/views/app/ContentItemDetailView.vue b/frontend/src/views/app/ContentItemDetailView.vue new file mode 100644 index 0000000..17d716d --- /dev/null +++ b/frontend/src/views/app/ContentItemDetailView.vue @@ -0,0 +1,1201 @@ + + + + + diff --git a/frontend/src/views/app/ContentItemsView.vue b/frontend/src/views/app/ContentItemsView.vue new file mode 100644 index 0000000..9d25a60 --- /dev/null +++ b/frontend/src/views/app/ContentItemsView.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/views/app/DashboardView.vue b/frontend/src/views/app/DashboardView.vue new file mode 100644 index 0000000..996c4db --- /dev/null +++ b/frontend/src/views/app/DashboardView.vue @@ -0,0 +1,620 @@ + + + + + diff --git a/frontend/src/views/app/IntegrationsSettingsView.vue b/frontend/src/views/app/IntegrationsSettingsView.vue new file mode 100644 index 0000000..9e68f25 --- /dev/null +++ b/frontend/src/views/app/IntegrationsSettingsView.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/src/views/app/MediaLibraryView.vue b/frontend/src/views/app/MediaLibraryView.vue new file mode 100644 index 0000000..bff9e31 --- /dev/null +++ b/frontend/src/views/app/MediaLibraryView.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/frontend/src/views/app/OverviewView.vue b/frontend/src/views/app/OverviewView.vue new file mode 100644 index 0000000..2f07f5d --- /dev/null +++ b/frontend/src/views/app/OverviewView.vue @@ -0,0 +1,418 @@ + + + + + diff --git a/frontend/src/views/app/ProjectDetailView.vue b/frontend/src/views/app/ProjectDetailView.vue new file mode 100644 index 0000000..14ce21a --- /dev/null +++ b/frontend/src/views/app/ProjectDetailView.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/frontend/src/views/app/ProjectsView.vue b/frontend/src/views/app/ProjectsView.vue new file mode 100644 index 0000000..5b650e9 --- /dev/null +++ b/frontend/src/views/app/ProjectsView.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/views/app/ReviewQueueView.vue b/frontend/src/views/app/ReviewQueueView.vue new file mode 100644 index 0000000..3e44056 --- /dev/null +++ b/frontend/src/views/app/ReviewQueueView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/views/app/SettingsLayoutView.vue b/frontend/src/views/app/SettingsLayoutView.vue new file mode 100644 index 0000000..a98cb3c --- /dev/null +++ b/frontend/src/views/app/SettingsLayoutView.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/views/app/UserSettingsView.vue b/frontend/src/views/app/UserSettingsView.vue new file mode 100644 index 0000000..5b6feb9 --- /dev/null +++ b/frontend/src/views/app/UserSettingsView.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/frontend/src/views/app/WorkspaceCreateView.vue b/frontend/src/views/app/WorkspaceCreateView.vue new file mode 100644 index 0000000..ca11639 --- /dev/null +++ b/frontend/src/views/app/WorkspaceCreateView.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/src/views/app/WorkspaceSettingsView.vue b/frontend/src/views/app/WorkspaceSettingsView.vue new file mode 100644 index 0000000..c214aba --- /dev/null +++ b/frontend/src/views/app/WorkspaceSettingsView.vue @@ -0,0 +1,559 @@ + + + + + diff --git a/frontend/src/views/creators/AboutCreator.vue b/frontend/src/views/creators/AboutCreator.vue deleted file mode 100644 index e0fce58..0000000 --- a/frontend/src/views/creators/AboutCreator.vue +++ /dev/null @@ -1,686 +0,0 @@ - - - - - - - -{ - "en": { - "edit": "Edit", - "save": "Save", - "cancel": "Cancel", - "creator": { - "sections": { - "about": { - "title": "About", - "description": "Description", - "contactInfo": "Contact Information", - "characters": "characters", - "formattingHint": "Tip: Use line breaks and emojis to make your description more engaging!" - }, - "photos": { - "title": "Photos", - "image": "Image" - } - }, - "fields": { - "videoUrl": "Video URL", - "phoneNumber": "Phone Number", - "email": "Email" - }, - "validation": { - "invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID", - "descriptionTooLong": "Description cannot exceed 2000 characters", - "descriptionRequired": "Description is required" - } - } - }, - "fr": { - "edit": "Modifier", - "save": "Enregistrer", - "cancel": "Annuler", - "creator": { - "sections": { - "about": { - "title": "À propos", - "description": "Description", - "contactInfo": "Informations de contact", - "characters": "caractères", - "formattingHint": "Astuce : Utilisez des sauts de ligne et des émojis pour rendre votre description plus attrayante !" - }, - "photos": { - "title": "Photos", - "image": "Image" - } - }, - "fields": { - "videoUrl": "URL de la vidéo", - "phoneNumber": "Numéro de téléphone", - "email": "Email" - }, - "validation": { - "invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide", - "descriptionTooLong": "La description ne peut pas dépasser 2000 caractères", - "descriptionRequired": "La description est obligatoire" - } - } - } -} - diff --git a/frontend/src/views/creators/ActualBanner.vue b/frontend/src/views/creators/ActualBanner.vue deleted file mode 100644 index 981ffbc..0000000 --- a/frontend/src/views/creators/ActualBanner.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - -{ - "en": { - "alt": "Creator banner" - }, - "fr": { - "alt": "Bannière du créateur" - } -} - diff --git a/frontend/src/views/creators/AlbumEditor.vue b/frontend/src/views/creators/AlbumEditor.vue deleted file mode 100644 index 11e1145..0000000 --- a/frontend/src/views/creators/AlbumEditor.vue +++ /dev/null @@ -1,367 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Album", - "dropzoneText": "Drop a photo here to add it to your album", - "processing": "Processing...", - "uploading": "Uploading...", - "moveLeft": "Move Left", - "moveRight": "Move Right", - "delete": "Delete" - }, - "fr": { - "title": "Album", - "dropzoneText": "Déposez une photo ici pour l'ajouter à l'album", - "processing": "Traitement en cours...", - "uploading": "Téléchargement...", - "moveLeft": "Déplacer à gauche", - "moveRight": "Déplacer à droite", - "delete": "Supprimer" - } -} - diff --git a/frontend/src/views/creators/AlbumView.vue b/frontend/src/views/creators/AlbumView.vue deleted file mode 100644 index 0de66fd..0000000 --- a/frontend/src/views/creators/AlbumView.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - -{ - "en": { - "creator": { - "sections": { - "album": { - "title": "Photo Album", - "image": "Album image" - } - } - } - }, - "fr": { - "creator": { - "sections": { - "album": { - "title": "Album photo", - "image": "Image de l'album" - } - } - } - } -} - \ No newline at end of file diff --git a/frontend/src/views/creators/AlbumViewer.vue b/frontend/src/views/creators/AlbumViewer.vue deleted file mode 100644 index 9e2649b..0000000 --- a/frontend/src/views/creators/AlbumViewer.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - -{ - "en": { - "viewer": { - "previous": "Previous image", - "next": "Next image", - "close": "Close viewer", - "imageAlt": "Image {index}" - } - }, - "fr": { - "viewer": { - "previous": "Image précédente", - "next": "Image suivante", - "close": "Fermer", - "imageAlt": "Image {index}" - } - } -} - diff --git a/frontend/src/views/creators/BannerActions.vue b/frontend/src/views/creators/BannerActions.vue deleted file mode 100644 index 57e4248..0000000 --- a/frontend/src/views/creators/BannerActions.vue +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - -{ - "en": { - "facebook": "Facebook", - "instagram": "Instagram", - "linkedin": "LinkedIn", - "reddit": "Reddit", - "tiktok": "TikTok", - "x": "X (Twitter)", - "youtube": "YouTube", - "website": "Website" - }, - "fr": { - "facebook": "Facebook", - "instagram": "Instagram", - "linkedin": "LinkedIn", - "reddit": "Reddit", - "tiktok": "TikTok", - "x": "X (Twitter)", - "youtube": "YouTube", - "website": "Site web" - } -} - \ No newline at end of file diff --git a/frontend/src/views/creators/BannerEditor.vue b/frontend/src/views/creators/BannerEditor.vue deleted file mode 100644 index 7073f6b..0000000 --- a/frontend/src/views/creators/BannerEditor.vue +++ /dev/null @@ -1,377 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Banner Editor", - "description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).", - "chooseImage": "Choose an image", - "clickToEdit": "Click to edit", - "uploading": "Uploading" - }, - "fr": { - "title": "Éditeur de bannière", - "description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).", - "chooseImage": "Choisir une image", - "clickToEdit": "Cliquez pour modifier", - "uploading": "Téléchargement" - } -} - diff --git a/frontend/src/views/creators/CreateCreator.vue b/frontend/src/views/creators/CreateCreator.vue deleted file mode 100644 index 943820b..0000000 --- a/frontend/src/views/creators/CreateCreator.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Create your Hutopy", - "cancel": "Cancel", - "create": "Create my page", - "errors": { - "unexpected": "An unexpected error occurred" - } - }, - "fr": { - "title": "Créez votre Hutopy", - "cancel": "Annuler", - "create": "Créer ma page", - "errors": { - "unexpected": "Une erreur inattendue s'est produite" - } - } -} - diff --git a/frontend/src/views/creators/CreatorHome.vue b/frontend/src/views/creators/CreatorHome.vue deleted file mode 100644 index 8500735..0000000 --- a/frontend/src/views/creators/CreatorHome.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/frontend/src/views/creators/CreatorLayout.vue b/frontend/src/views/creators/CreatorLayout.vue deleted file mode 100644 index f3517b8..0000000 --- a/frontend/src/views/creators/CreatorLayout.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - - - -{ - "en": { - "creator": { - "layout": { - "deletion": { - "pending": "This creator page is pending deletion" - } - }, - "notFound": { - "title": "Creator Not Found", - "message": "The creator '{creator}' doesn't exist or may have been removed.", - "goHome": "Go to Home", - "goBack": "Go Back" - }, - "error": { - "title": "Something Went Wrong", - "message": "We're having trouble loading this creator page. Please try again later.", - "goHome": "Go to Home" - } - } - }, - "fr": { - "creator": { - "layout": { - "deletion": { - "pending": "Cette page créateur est en attente de suppression" - } - }, - "notFound": { - "title": "Créateur Introuvable", - "message": "Le créateur '{creator}' n'existe pas ou a peut-être été supprimé.", - "goHome": "Aller à l'accueil", - "goBack": "Retour" - }, - "error": { - "title": "Quelque chose s'est mal passé", - "message": "Nous avons des difficultés à charger cette page créateur. Veuillez réessayer plus tard.", - "goHome": "Aller à l'accueil" - } - } - } -} - diff --git a/frontend/src/views/creators/CreatorLogo.vue b/frontend/src/views/creators/CreatorLogo.vue deleted file mode 100644 index 37c6d65..0000000 --- a/frontend/src/views/creators/CreatorLogo.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - -{ - "en": { - "logoAlt": "Creator logo", - "editLogo": "Edit logo" - }, - "fr": { - "logoAlt": "Logo du créateur", - "editLogo": "Modifier le logo" - } -} - diff --git a/frontend/src/views/creators/CreatorLogoEditor.vue b/frontend/src/views/creators/CreatorLogoEditor.vue deleted file mode 100644 index ee53695..0000000 --- a/frontend/src/views/creators/CreatorLogoEditor.vue +++ /dev/null @@ -1,398 +0,0 @@ - - - - - - - -{ - "en": { - "logoTitle": "Edit Logo", - "logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.", - "chooseImage": "Choose Image", - "clickToEdit": "Click to edit", - "uploading": "Uploading" - }, - "fr": { - "logoTitle": "Modifier le logo", - "logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.", - "chooseImage": "Choisir une image", - "clickToEdit": "Cliquez pour modifier", - "uploading": "Téléchargement" - } -} - diff --git a/frontend/src/views/creators/DonationButton.vue b/frontend/src/views/creators/DonationButton.vue deleted file mode 100644 index 8c6b50e..0000000 --- a/frontend/src/views/creators/DonationButton.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - -{ - "en": { - "creator": { - "donation": { - "isupport": "I Support" - } - } - }, - "fr": { - "creator": { - "donation": { - "isupport": "Je Soutiens" - } - } - } -} - diff --git a/frontend/src/views/creators/DonationDialog.vue b/frontend/src/views/creators/DonationDialog.vue deleted file mode 100644 index 7fdf6a1..0000000 --- a/frontend/src/views/creators/DonationDialog.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/creators/DonationForm.vue b/frontend/src/views/creators/DonationForm.vue deleted file mode 100644 index 00c13fe..0000000 --- a/frontend/src/views/creators/DonationForm.vue +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - -{ - "en": { - "common": { - "cancel": "Cancel" - }, - "creator": { - "donation": { - "isupport": "I Support", - "amount": "Amount ($)", - "message": "Message (optional)", - "send": "Send", - "processing": "Processing...", - "errors": { - "payment": "An error occurred during payment processing", - "invalidAmount": "Please enter a valid amount" - } - } - } - }, - "fr": { - "common": { - "cancel": "Annuler" - }, - "creator": { - "donation": { - "isupport": "Je Soutiens", - "amount": "Montant ($)", - "message": "Message (optionnel)", - "send": "Envoyer", - "processing": "Traitement en cours...", - "errors": { - "payment": "Une erreur s'est produite lors du traitement du paiement", - "invalidAmount": "Veuillez entrer un montant valide" - } - } - } - } -} - diff --git a/frontend/src/views/creators/NameEditor.vue b/frontend/src/views/creators/NameEditor.vue deleted file mode 100644 index 6e5b2ab..0000000 --- a/frontend/src/views/creators/NameEditor.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - -{ - "en": { - "creator": { - "name": { - "label": "Your creator handle", - "errors": { - "required": "Creator handle is required", - "invalid": "Only letters, numbers, and hyphens are allowed" - } - } - } - }, - "fr": { - "creator": { - "name": { - "label": "Votre identifiant de créateur", - "errors": { - "required": "L'identifiant est requis", - "invalid": "Seules les lettres, chiffres et tirets sont autorisés" - } - } - } - } -} - diff --git a/frontend/src/views/creators/NameTitle.vue b/frontend/src/views/creators/NameTitle.vue deleted file mode 100644 index e4a8d03..0000000 --- a/frontend/src/views/creators/NameTitle.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - - - -{ - "en": { - "verified": "Verified Account" - }, - "fr": { - "verified": "Compte vérifié" - } -} - diff --git a/frontend/src/views/creators/PaymentCompleted.vue b/frontend/src/views/creators/PaymentCompleted.vue deleted file mode 100644 index 97715a3..0000000 --- a/frontend/src/views/creators/PaymentCompleted.vue +++ /dev/null @@ -1,148 +0,0 @@ - - - - - -{ - "en": { - "title": "{creatorName} thanks you!", - "message": "Your payment has been processed successfully.", - "usernameDefault": "The creator", - "receipt": "A receipt has been sent to your email.", - "returnToCreator": "Return to creator page" - }, - "fr": { - "title": "{creatorName} vous remercie !", - "message": "Votre paiement a été traité avec succès.", - "usernameDefault": "Le créateur", - "receipt": "Un reçu a été envoyé à votre email.", - "returnToCreator": "Retourner à la page du créateur" - } -} - - - diff --git a/frontend/src/views/creators/PaymentFailed.vue b/frontend/src/views/creators/PaymentFailed.vue deleted file mode 100644 index f7e2a5d..0000000 --- a/frontend/src/views/creators/PaymentFailed.vue +++ /dev/null @@ -1,119 +0,0 @@ - - - - - -{ - "en": { - "title": "Payment Failed", - "message": "We couldn't process your payment.", - "retry": "Try Again", - "returnToCreator": "Return to creator page" - }, - "fr": { - "title": "Échec du paiement", - "message": "Nous n'avons pas pu traiter votre paiement.", - "retry": "Réessayer", - "returnToCreator": "Retourner à la page du créateur" - } -} - - - diff --git a/frontend/src/views/documentation/About.vue b/frontend/src/views/documentation/About.vue deleted file mode 100644 index 79a48d2..0000000 --- a/frontend/src/views/documentation/About.vue +++ /dev/null @@ -1,194 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/ContentPolicy.vue b/frontend/src/views/documentation/ContentPolicy.vue deleted file mode 100644 index cfb7299..0000000 --- a/frontend/src/views/documentation/ContentPolicy.vue +++ /dev/null @@ -1,275 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/CreatorGuide.vue b/frontend/src/views/documentation/CreatorGuide.vue deleted file mode 100644 index ca901d7..0000000 --- a/frontend/src/views/documentation/CreatorGuide.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/DocumentationLayout.vue b/frontend/src/views/documentation/DocumentationLayout.vue deleted file mode 100644 index 7870f2d..0000000 --- a/frontend/src/views/documentation/DocumentationLayout.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/FAQ.vue b/frontend/src/views/documentation/FAQ.vue deleted file mode 100644 index a48735c..0000000 --- a/frontend/src/views/documentation/FAQ.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/frontend/src/views/documentation/HelpAndContact.vue b/frontend/src/views/documentation/HelpAndContact.vue deleted file mode 100644 index 42122a5..0000000 --- a/frontend/src/views/documentation/HelpAndContact.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/Pricing.vue b/frontend/src/views/documentation/Pricing.vue deleted file mode 100644 index 5ecbe6e..0000000 --- a/frontend/src/views/documentation/Pricing.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/TermsAndConditions.vue b/frontend/src/views/documentation/TermsAndConditions.vue deleted file mode 100644 index 12c0c70..0000000 --- a/frontend/src/views/documentation/TermsAndConditions.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/views/documentation/documentation.css b/frontend/src/views/documentation/documentation.css deleted file mode 100644 index 6a2bfc9..0000000 --- a/frontend/src/views/documentation/documentation.css +++ /dev/null @@ -1,25 +0,0 @@ -h1 { - @apply text-hOnBackground; - @apply flex items-center uppercase; - @apply font-sans font-bold text-4xl md:text-8xl; - @apply tracking-widest; - @apply mb-12; -} - -h2 { - @apply text-hOnBackground; - @apply font-sans font-bold text-2xl md:text-4xl; - @apply mb-6; -} - -p { - @apply text-hOnBackground; - @apply font-sans font-normal text-lg; - @apply tracking-normal; - @apply mb-6; - @apply text-justify; -} - -ul { - @apply mb-6; -} \ No newline at end of file diff --git a/frontend/src/views/main/AppBar.vue b/frontend/src/views/main/AppBar.vue new file mode 100644 index 0000000..f067ccd --- /dev/null +++ b/frontend/src/views/main/AppBar.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/frontend/src/views/main/AppSidebar.vue b/frontend/src/views/main/AppSidebar.vue new file mode 100644 index 0000000..27aa561 --- /dev/null +++ b/frontend/src/views/main/AppSidebar.vue @@ -0,0 +1,895 @@ + + + + + diff --git a/frontend/src/views/main/Footer.vue b/frontend/src/views/main/Footer.vue deleted file mode 100644 index d33fd8b..0000000 --- a/frontend/src/views/main/Footer.vue +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - -{ - "en": { - "footer": { - "helpandcontact": "Help & Contact", - "faq": "FAQ", - "creatorguide": "Creator Guide", - "termsandconditions": "Terms & Conditions", - "contentpolicy": "Content Policy", - "about": "About", - "pricing": "Pricing", - "allRightsReserved": "All Rights Reserved" - } - }, - "fr": { - "footer": { - "helpandcontact": "Aide & Contact", - "faq": "FAQ", - "creatorguide": "Guide du Créateur", - "termsandconditions": "Conditions Générales", - "contentpolicy": "Politique de Contenu", - "about": "À Propos", - "pricing": "Tarifs", - "allRightsReserved": "Tous Droits Réservés" - } - } -} - diff --git a/frontend/src/views/main/Landing.vue b/frontend/src/views/main/Landing.vue index d49200d..ba88b0d 100644 --- a/frontend/src/views/main/Landing.vue +++ b/frontend/src/views/main/Landing.vue @@ -1,309 +1,192 @@ - - -{ - "en": { - "inscription": "Sign Up", - "createPage": "Create Page", - "support": "Support", - "creators": "Creators", - "projects": "Projects", - "love": "Love", - "supportText": "Support", - "supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.", - "create": "Create", - "creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.", - "signup": "Sign Up", - "whatIsHutopy": "What is Hutopy?", - "hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.", - "hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers." - }, - "fr": { - "inscription": "S'inscrire", - "createPage": "Créer une Page", - "support": "Soutenir", - "creators": "Créateurs", - "projects": "Projets", - "love": "Passion", - "supportText": "Soutenir", - "supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.", - "create": "Créer", - "creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.", - "signup": "S'inscrire", - "whatIsHutopy": "Qu'est-ce que Hutopy ?", - "hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.", - "hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutien. Nous croyons en l'autonomisation des créateurs pour poursuivre leurs passions et construire des carrières durables." - } -} - diff --git a/frontend/src/views/main/SiteBar.vue b/frontend/src/views/main/SiteBar.vue deleted file mode 100644 index 9277ef8..0000000 --- a/frontend/src/views/main/SiteBar.vue +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - -{ - "en": { - "sidebar": { - "myPage": "My Page", - "myProfile": "My Profile", - "signIn": "Sign In", - "signOut": "Sign Out" - } - }, - "fr": { - "sidebar": { - "myPage": "Ma Page", - "myProfile": "Mon Profil", - "signIn": "Se Connecter", - "signOut": "Se Déconnecter" - } - } -} - diff --git a/frontend/src/views/profile/ProfilePage.vue b/frontend/src/views/profile/ProfilePage.vue deleted file mode 100644 index 45abd63..0000000 --- a/frontend/src/views/profile/ProfilePage.vue +++ /dev/null @@ -1,953 +0,0 @@ - - - - - - - -{ - "en": { - "personalInfo": "Personal Information", - "fullName": "Full Name", - "alias": "Alias", - "email": "Email", - "changePassword": "Update Password", - "creatorInfo": "Creator Information", - "dangerZone": "Danger Zone", - "dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.", - "deleteWarning": "Are you sure you want to delete your creator page? This action cannot be undone.", - "restoreWarning": "Are you sure you want to restore your creator page? This will make your page visible again.", - "deleteCreatorPage": "Delete Creator Page", - "restoreCreatorPage": "Restore Creator Page", - "stripeAccountId": "Stripe Account ID", - "socialNetworks": "Social Networks", - "handle": "Creator Handle", - "qrCode": "QR Code", - "qrCodeDescription": "Print this QR code to share your Hutopy with the world! Perfect for business cards, social media, and promotional materials.", - "downloadQRCode": "Download QR Code", - "payment-information": "Payment Information", - "stripeStatus": "Stripe Status", - "configured": "Configured", - "notConfigured": "Not Configured", - "needsMoreInfo": "Requires More Information", - "pendingVerification": "Pending Verification", - "continueStripeSetup": "Continue Stripe Setup", - "reviewStripe": "Review Stripe", - "notSet": "Not Set", - "configureStripe": "Connect Stripe", - "phoneNumber": "Phone Number", - "title": "Title", - "removeStripe": "Remove Stripe" - }, - "fr": { - "personalInfo": "Informations Personnelles", - "fullName": "Nom Complet", - "alias": "Alias", - "email": "Email", - "changePassword": "Modifier le mot de passe", - "creatorInfo": "Informations du Créateur", - "dangerZone": "Zone de Danger", - "dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.", - "deleteWarning": "Êtes-vous sûr de vouloir supprimer votre page de créateur ? Cette action est irréversible.", - "restoreWarning": "Êtes-vous sûr de vouloir restaurer votre page de créateur ? Cela rendra votre page à nouveau visible.", - "deleteCreatorPage": "Supprimer la Page Créateur", - "restoreCreatorPage": "Restaurer la Page Créateur", - "stripeAccountId": "ID de Compte Stripe", - "socialNetworks": "Réseaux Sociaux", - "handle": "Identifiant du créateur", - "qrCode": "Code QR", - "qrCodeDescription": "Imprimez ce code QR pour partager votre Hutopy avec le monde ! Parfait pour les cartes de visite, les réseaux sociaux et les supports promotionnels.", - "downloadQRCode": "Télécharger le Code QR", - "payment-information": "Informations de Paiement", - "stripeStatus": "État de Stripe", - "configured": "Configuré", - "notConfigured": "Non Configuré", - "needsMoreInfo": "Demande plus d'informations", - "pendingVerification": "Vérification en Cours", - "continueStripeSetup": "Continuer Configuration Stripe", - "reviewStripe": "Reviser Stripe", - "notSet": "Non Défini", - "configureStripe": "Connecter Stripe", - "phoneNumber": "Numéro de téléphone", - "title": "Titre", - "removeStripe": "Retirer Stripe" - } -} - diff --git a/frontend/src/views/profile/account/AliasDialog.vue b/frontend/src/views/profile/account/AliasDialog.vue deleted file mode 100644 index 5f23082..0000000 --- a/frontend/src/views/profile/account/AliasDialog.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - - - -{ - "en": { - "title": "Alias", - "label": "Your alias" - }, - "fr": { - "title": "Alias", - "label": "Votre alias" - } -} - diff --git a/frontend/src/views/profile/account/ChangePasswordDialog.vue b/frontend/src/views/profile/account/ChangePasswordDialog.vue deleted file mode 100644 index b2517d4..0000000 --- a/frontend/src/views/profile/account/ChangePasswordDialog.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - - -{ - "en": { - "changePassword": "Update Password", - "newPassword": "New Password", - "confirmPassword": "Confirm New Password", - "passwordRequirements": "Password must be at least 8 characters", - "passwordDescription": "Updating your password allows you to log in directly with your email and password.", - "save": "Save", - "cancel": "Cancel", - "passwordsDoNotMatch": "New passwords do not match", - "passwordTooShort": "Password must be at least 8 characters long", - "passwordUpdateFailed": "Failed to update password. Please try again." - }, - "fr": { - "changePassword": "Modifier le mot de passe", - "newPassword": "Nouveau mot de passe", - "confirmPassword": "Confirmer le nouveau mot de passe", - "passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères", - "passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.", - "save": "Enregistrer", - "cancel": "Annuler", - "passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas", - "passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères", - "passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer." - } -} - - diff --git a/frontend/src/views/profile/account/EmailDialog.vue b/frontend/src/views/profile/account/EmailDialog.vue deleted file mode 100644 index 0056d71..0000000 --- a/frontend/src/views/profile/account/EmailDialog.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - -{ - "en": { - "title": "Change your Email", - "label": "Your email" - }, - "fr": { - "title": "Changez votre Courriel", - "label": "Votre email" - } -} - diff --git a/frontend/src/views/profile/account/FullnameDialog.vue b/frontend/src/views/profile/account/FullnameDialog.vue deleted file mode 100644 index 059c567..0000000 --- a/frontend/src/views/profile/account/FullnameDialog.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - -{ - "en": { - "title": "Full Name", - "firstname": "First Name", - "lastname": "Last Name" - }, - "fr": { - "title": "Nom complet", - "firstname": "Prénom", - "lastname": "Nom" - } -} - \ No newline at end of file diff --git a/frontend/src/views/profile/creators/ChangeEmailDialog.vue b/frontend/src/views/profile/creators/ChangeEmailDialog.vue deleted file mode 100644 index 0e981b5..0000000 --- a/frontend/src/views/profile/creators/ChangeEmailDialog.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - -{ - "en": { - "changeEmail": "Change Email", - "email": "Email", - "save": "Save", - "cancel": "Cancel", - "validation": { - "emailRequired": "Email is required", - "emailInvalid": "Please enter a valid email address" - }, - "errors": { - "unexpected": "An unexpected error occurred" - } - }, - "fr": { - "changeEmail": "Modifier l'email", - "email": "Email", - "save": "Enregistrer", - "cancel": "Annuler", - "validation": { - "emailRequired": "L'email est requis", - "emailInvalid": "Veuillez entrer une adresse email valide" - }, - "errors": { - "unexpected": "Une erreur inattendue s'est produite" - } - } -} - \ No newline at end of file diff --git a/frontend/src/views/profile/creators/ChangeNameDialog.vue b/frontend/src/views/profile/creators/ChangeNameDialog.vue deleted file mode 100644 index 643165e..0000000 --- a/frontend/src/views/profile/creators/ChangeNameDialog.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Change Name", - "label": "Your name" - }, - "fr": { - "title": "Modifier le nom", - "label": "Votre nom" - } -} - diff --git a/frontend/src/views/profile/creators/ChangePhoneDialog.vue b/frontend/src/views/profile/creators/ChangePhoneDialog.vue deleted file mode 100644 index f89d010..0000000 --- a/frontend/src/views/profile/creators/ChangePhoneDialog.vue +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - - -{ - "en": { - "changePhoneNumber": "Change Phone Number", - "phoneNumber": "Phone Number", - "phonePlaceholder": "(555) 123-4567", - "save": "Save", - "cancel": "Cancel", - "validation": { - "phoneRequired": "Phone number is required", - "phoneInvalid": "Please enter a complete 10-digit phone number" - }, - "errors": { - "unexpected": "An unexpected error occurred" - } - }, - "fr": { - "changePhoneNumber": "Modifier le numéro de téléphone", - "phoneNumber": "Numéro de téléphone", - "phonePlaceholder": "(555) 123-4567", - "save": "Enregistrer", - "cancel": "Annuler", - "validation": { - "phoneRequired": "Le numéro de téléphone est requis", - "phoneInvalid": "Veuillez entrer un numéro de téléphone complet à 10 chiffres" - }, - "errors": { - "unexpected": "Une erreur inattendue s'est produite" - } - } -} - \ No newline at end of file diff --git a/frontend/src/views/profile/creators/ChangeSlugDialog.vue b/frontend/src/views/profile/creators/ChangeSlugDialog.vue deleted file mode 100644 index 080ccf9..0000000 --- a/frontend/src/views/profile/creators/ChangeSlugDialog.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Change Creator Handle" - }, - "fr": { - "title": "Modifier l'identifiant du créateur" - } -} - \ No newline at end of file diff --git a/frontend/src/views/profile/creators/ChangeStripeIdDialog.vue b/frontend/src/views/profile/creators/ChangeStripeIdDialog.vue deleted file mode 100644 index 80fc324..0000000 --- a/frontend/src/views/profile/creators/ChangeStripeIdDialog.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Change Stripe ID", - "label": "Your Stripe ID" - }, - "fr": { - "title": "Modifier l'ID Stripe", - "label": "Votre ID Stripe" - } -} - diff --git a/frontend/src/views/profile/creators/ChangeTitleDialog.vue b/frontend/src/views/profile/creators/ChangeTitleDialog.vue deleted file mode 100644 index 5682694..0000000 --- a/frontend/src/views/profile/creators/ChangeTitleDialog.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Change Title", - "label": "Your title" - }, - "fr": { - "title": "Modifier le titre", - "label": "Votre titre" - } -} - diff --git a/frontend/src/views/profile/creators/SocialsDialog.vue b/frontend/src/views/profile/creators/SocialsDialog.vue deleted file mode 100644 index de930e3..0000000 --- a/frontend/src/views/profile/creators/SocialsDialog.vue +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Social Media Links" - }, - "fr": { - "title": "Liens des réseaux sociaux" - } -} - diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 480c589..b130ed2 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,16 +1,12 @@ import { fileURLToPath, URL } from 'node:url' -import { defineConfig, loadEnv } from 'vite' +import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -import fs from 'fs'; import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' import { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => { - // Load environment variables based on the mode - const env = loadEnv(mode, process.cwd(), '') - return { +export default defineConfig({ plugins: [ visualizer({ filename: './dist/stats.html', @@ -24,10 +20,6 @@ export default defineConfig(({ mode }) => { }) ], server: { - https: { - key: fs.readFileSync('localhost-key.pem'), - cert: fs.readFileSync('localhost.pem'), - }, port: 5173, // Ensure this matches your WebStorm debug URL open: true, // Automatically opens the browser host: '0.0.0.0', @@ -54,13 +46,7 @@ export default defineConfig(({ mode }) => { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, - define: { - // Define a global constant __APP_ENV__ based on loaded environment variables - VITE_API_URL: JSON.stringify(env.VITE_API_URL), - VITE_STRIPE_API_KEY: JSON.stringify(env.VITE_STRIPE_API_KEY) - }, json: { stringify: false } - } })