diff --git a/.sqlx/query-14d6f116e96d1a14e4f9012505c65b4fb5054857d28d6c91efe581a96394cd95.json b/.sqlx/query-14d6f116e96d1a14e4f9012505c65b4fb5054857d28d6c91efe581a96394cd95.json deleted file mode 100644 index e7d63f41..00000000 --- a/.sqlx/query-14d6f116e96d1a14e4f9012505c65b4fb5054857d28d6c91efe581a96394cd95.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE organizations\n SET current_subscription = $2\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "14d6f116e96d1a14e4f9012505c65b4fb5054857d28d6c91efe581a96394cd95" -} diff --git a/.sqlx/query-1a243b9b6d1530826d837de97574ca2d9346c3905b109bbc30bb5a6431091377.json b/.sqlx/query-1a243b9b6d1530826d837de97574ca2d9346c3905b109bbc30bb5a6431091377.json new file mode 100644 index 00000000..3b5ba2c4 --- /dev/null +++ b/.sqlx/query-1a243b9b6d1530826d837de97574ca2d9346c3905b109bbc30bb5a6431091377.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT current_subscription\n FROM organizations\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "current_subscription", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1a243b9b6d1530826d837de97574ca2d9346c3905b109bbc30bb5a6431091377" +} diff --git a/.sqlx/query-57d9a1e644c9243ceab7d33cb983480e0b8701de3f83c79d4959fc6d70df9790.json b/.sqlx/query-2591893c48c8aae12c9face136b5981a18ab9369f7164d41120876e2cc0448ad.json similarity index 79% rename from .sqlx/query-57d9a1e644c9243ceab7d33cb983480e0b8701de3f83c79d4959fc6d70df9790.json rename to .sqlx/query-2591893c48c8aae12c9face136b5981a18ab9369f7164d41120876e2cc0448ad.json index 00bf5b98..874d57d7 100644 --- a/.sqlx/query-57d9a1e644c9243ceab7d33cb983480e0b8701de3f83c79d4959fc6d70df9790.json +++ b/.sqlx/query-2591893c48c8aae12c9face136b5981a18ab9369f7164d41120876e2cc0448ad.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO projects (id, organization_id, name)\n VALUES (gen_random_uuid(), $1, $2)\n RETURNING *\n ", + "query": "\n INSERT INTO projects (id, organization_id, name, retention_period_days)\n VALUES (gen_random_uuid(), $1, $2, $3)\n RETURNING *\n ", "describe": { "columns": [ { @@ -37,7 +37,8 @@ "parameters": { "Left": [ "Uuid", - "Varchar" + "Varchar", + "Int4" ] }, "nullable": [ @@ -49,5 +50,5 @@ false ] }, - "hash": "57d9a1e644c9243ceab7d33cb983480e0b8701de3f83c79d4959fc6d70df9790" + "hash": "2591893c48c8aae12c9face136b5981a18ab9369f7164d41120876e2cc0448ad" } diff --git a/.sqlx/query-3a98c1af6bc82460788f2ac4f420d20a20f9d66f96d155c002541950755d949e.json b/.sqlx/query-3a98c1af6bc82460788f2ac4f420d20a20f9d66f96d155c002541950755d949e.json new file mode 100644 index 00000000..87c106ed --- /dev/null +++ b/.sqlx/query-3a98c1af6bc82460788f2ac4f420d20a20f9d66f96d155c002541950755d949e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE messages m\n SET raw_data = '',\n message_data = NULL,\n recipients = '{}',\n delivery_details = '{}'\n FROM projects p\n WHERE m.project_id = p.id\n AND m.created_at < NOW() - (p.retention_period_days * INTERVAL '1 day')\n AND octet_length(m.raw_data) > 0;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3a98c1af6bc82460788f2ac4f420d20a20f9d66f96d155c002541950755d949e" +} diff --git a/.sqlx/query-d1a0a2eac92e8b211cbbefdd214c2142b6ebdf5171fbaab6b66185475fb1703c.json b/.sqlx/query-6019031dbd6be02050e34e453b1a324c429aafe2b26fe7d82fe20da2f96b7a1a.json similarity index 77% rename from .sqlx/query-d1a0a2eac92e8b211cbbefdd214c2142b6ebdf5171fbaab6b66185475fb1703c.json rename to .sqlx/query-6019031dbd6be02050e34e453b1a324c429aafe2b26fe7d82fe20da2f96b7a1a.json index d3d7db33..ce850c0e 100644 --- a/.sqlx/query-d1a0a2eac92e8b211cbbefdd214c2142b6ebdf5171fbaab6b66185475fb1703c.json +++ b/.sqlx/query-6019031dbd6be02050e34e453b1a324c429aafe2b26fe7d82fe20da2f96b7a1a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE projects \n SET name = $3 \n WHERE id = $2\n AND organization_id = $1\n RETURNING *\n ", + "query": "\n UPDATE projects \n SET name = $3,\n retention_period_days = $4\n WHERE id = $2\n AND organization_id = $1\n RETURNING *\n ", "describe": { "columns": [ { @@ -38,7 +38,8 @@ "Left": [ "Uuid", "Uuid", - "Varchar" + "Varchar", + "Int4" ] }, "nullable": [ @@ -50,5 +51,5 @@ false ] }, - "hash": "d1a0a2eac92e8b211cbbefdd214c2142b6ebdf5171fbaab6b66185475fb1703c" + "hash": "6019031dbd6be02050e34e453b1a324c429aafe2b26fe7d82fe20da2f96b7a1a" } diff --git a/.sqlx/query-81ab44acb4330019bdf4dacd374367d9b9b9157a31db88a2c99aaf02df9dc647.json b/.sqlx/query-81ab44acb4330019bdf4dacd374367d9b9b9157a31db88a2c99aaf02df9dc647.json new file mode 100644 index 00000000..8b4ed078 --- /dev/null +++ b/.sqlx/query-81ab44acb4330019bdf4dacd374367d9b9b9157a31db88a2c99aaf02df9dc647.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE projects\n SET retention_period_days = $2\n WHERE organization_id = $1\n AND retention_period_days > $2;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "81ab44acb4330019bdf4dacd374367d9b9b9157a31db88a2c99aaf02df9dc647" +} diff --git a/.sqlx/query-a6d00789a29b8924a9b4030d357d89e4acebeba4f6eb83631f16f1d5b5088a7d.json b/.sqlx/query-a6d00789a29b8924a9b4030d357d89e4acebeba4f6eb83631f16f1d5b5088a7d.json deleted file mode 100644 index 56d74b6e..00000000 --- a/.sqlx/query-a6d00789a29b8924a9b4030d357d89e4acebeba4f6eb83631f16f1d5b5088a7d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE messages\n SET raw_data = '',\n message_data = NULL,\n recipients = '{}',\n delivery_details = '{}'\n WHERE created_at < NOW() - INTERVAL '30 days'\n AND octet_length(raw_data) > 0;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "a6d00789a29b8924a9b4030d357d89e4acebeba4f6eb83631f16f1d5b5088a7d" -} diff --git a/.sqlx/query-eee5c2bd32d661206ef59b22224541c8ed0643b246101d0b91d54ce18474a488.json b/.sqlx/query-eee5c2bd32d661206ef59b22224541c8ed0643b246101d0b91d54ce18474a488.json new file mode 100644 index 00000000..7d5bf17e --- /dev/null +++ b/.sqlx/query-eee5c2bd32d661206ef59b22224541c8ed0643b246101d0b91d54ce18474a488.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET current_subscription = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "eee5c2bd32d661206ef59b22224541c8ed0643b246101d0b91d54ce18474a488" +} diff --git a/frontend/src/Pages.tsx b/frontend/src/Pages.tsx index 77c02c33..146f9447 100644 --- a/frontend/src/Pages.tsx +++ b/frontend/src/Pages.tsx @@ -57,7 +57,7 @@ function Page() { const { state: { organizations, routerState }, } = useRemails(); - const { subscription } = useSubscription(); + const { currentSubscription } = useSubscription(); if (organizations?.length === 0 && routerState.name != "invite") { return ; @@ -67,8 +67,8 @@ function Page() { !(routerState.name === "settings") && !routerState.name.startsWith("organizations") && !routerState.name.startsWith("admin") && - subscription && - subscription.status !== "active" + currentSubscription && + currentSubscription.status !== "active" ) { return ; } diff --git a/frontend/src/components/InfoTooltip.tsx b/frontend/src/components/InfoTooltip.tsx new file mode 100644 index 00000000..52386af8 --- /dev/null +++ b/frontend/src/components/InfoTooltip.tsx @@ -0,0 +1,25 @@ +import { MantineSize, ThemeIcon, Tooltip } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; + +interface InfoTooltipProps { + text: string; + size?: MantineSize | number; +} + +export default function InfoTooltip({ text, size }: InfoTooltipProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/SetupSubscription.tsx b/frontend/src/components/SetupSubscription.tsx index 6cbc9e4d..9a7990bd 100644 --- a/frontend/src/components/SetupSubscription.tsx +++ b/frontend/src/components/SetupSubscription.tsx @@ -5,10 +5,10 @@ import { IconReload } from "@tabler/icons-react"; import { useOrganizations } from "../hooks/useOrganizations.ts"; export default function SetupSubscription() { - const { subscription, reloadSubscription, navigateToSales } = useSubscription(); + const { currentSubscription, reloadSubscription, navigateToSales } = useSubscription(); const { currentOrganization } = useOrganizations(); - if (!currentOrganization || !subscription) { + if (!currentOrganization || !currentSubscription) { return null; } diff --git a/frontend/src/components/domains/DomainVerification.tsx b/frontend/src/components/domains/DomainVerification.tsx index b4cb7fb5..6133a848 100644 --- a/frontend/src/components/domains/DomainVerification.tsx +++ b/frontend/src/components/domains/DomainVerification.tsx @@ -2,13 +2,13 @@ import { useOrganizations } from "../../hooks/useOrganizations.ts"; import { useRemails } from "../../hooks/useRemails.ts"; import { useDomains } from "../../hooks/useDomains.ts"; import { VerifyResult } from "../../types.ts"; -import { IconInfoCircle } from "@tabler/icons-react"; -import { Badge, Button, Code, Group, Loader, Paper, Popover, Table, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { Badge, Button, Code, Group, Loader, Paper, Popover, Table, Text } from "@mantine/core"; import { dkimRecord, dmarcValue, spfRecord } from "./DnsRecords.tsx"; import { useVerifyDomain } from "../../hooks/useVerifyDomain.ts"; import { formatDateTime } from "../../util.ts"; import { CopyableCode } from "../CopyableCode.tsx"; import React, { useState } from "react"; +import InfoTooltip from "../InfoTooltip.tsx"; const badgeColors: { [key in VerifyResult["status"]]: string } = { Success: "green", @@ -157,11 +157,7 @@ export default function DomainVerification() { recordValue: ( any - - - - - + ), verifyResult: verificationResult?.a, diff --git a/frontend/src/components/emails/EmailOverview.tsx b/frontend/src/components/emails/EmailOverview.tsx index 4dcea78b..0004260f 100644 --- a/frontend/src/components/emails/EmailOverview.tsx +++ b/frontend/src/components/emails/EmailOverview.tsx @@ -1,31 +1,9 @@ -import { - Accordion, - ActionIcon, - Badge, - Box, - Button, - Code, - Group, - MultiSelect, - NativeSelect, - Text, - ThemeIcon, - Tooltip, -} from "@mantine/core"; +import { Accordion, ActionIcon, Badge, Box, Button, Code, Group, MultiSelect, NativeSelect, Text } from "@mantine/core"; import { useEmails } from "../../hooks/useEmails.ts"; import { Loader } from "../../Loader.tsx"; import { formatDateTime } from "../../util.ts"; import { useRemails } from "../../hooks/useRemails.ts"; -import { - IconArrowLeft, - IconArrowRight, - IconCheck, - IconClock, - IconEye, - IconInfoCircle, - IconRefresh, - IconX, -} from "@tabler/icons-react"; +import { IconArrowLeft, IconArrowRight, IconCheck, IconClock, IconEye, IconRefresh, IconX } from "@tabler/icons-react"; import { getFullStatusDescription } from "./EmailDetails.tsx"; import { DateTimePicker } from "@mantine/dates"; import dayjs from "dayjs"; @@ -38,6 +16,7 @@ import Label from "./Label.tsx"; import { EmailStatus } from "../../types.ts"; import OrganizationHeader from "../organizations/OrganizationHeader.tsx"; import ProjectLink from "../ProjectLink.tsx"; +import InfoTooltip from "../InfoTooltip.tsx"; function statusIcons(status: EmailStatus) { if (status == "processing" || status == "accepted") { @@ -184,18 +163,12 @@ export function EmailOverview() { <> {routerState.name == "emails" && } - {routerState.name == "emails" ? ( - - This page shows a list of all emails sent in this organization. Use it to check delivery status, inspect - metadata, and troubleshoot issues. You’ll see timestamps, recipient addresses, and SMTP-level details for each - message. - - ) : ( - - This page shows a list of all emails sent in this project. Use it to check delivery status, inspect metadata, - and troubleshoot issues. You’ll see timestamps, recipient addresses, and SMTP-level details for each message. - - )} + + This page shows a list of all emails sent in this {routerState.name == "emails" ? "organization" : "project"}. + Use it to check delivery status, inspect metadata, and troubleshoot issues. You’ll see timestamps, recipient + addresses, and SMTP-level details for each message. Messages are automatically deleted after the rentention + period set in the project settings. + @@ -203,11 +176,10 @@ export function EmailOverview() { label={ Label - - - - - + } placeholder="Pick labels" diff --git a/frontend/src/components/organizations/Members.tsx b/frontend/src/components/organizations/Members.tsx index 9fe28169..e27d42cd 100644 --- a/frontend/src/components/organizations/Members.tsx +++ b/frontend/src/components/organizations/Members.tsx @@ -19,7 +19,7 @@ import UpdateRole from "./UpdateRole"; export default function Members() { const { currentOrganization } = useOrganizations(); const { isAdmin } = useOrgRole(); - const { subscription } = useSubscription(); + const { currentSubscription } = useSubscription(); const { invites, setInvites } = useInvites(); const { members, setMembers } = useMembers(); const user = useSelector((state) => state.user); @@ -27,7 +27,7 @@ export default function Members() { const [opened, { open, close }] = useDisclosure(false); - if (!currentOrganization || !subscription) { + if (!currentOrganization || !currentSubscription) { return null; } @@ -217,7 +217,7 @@ export default function Members() { diff --git a/frontend/src/components/organizations/SubscriptionCard.tsx b/frontend/src/components/organizations/SubscriptionCard.tsx index 28b1b33f..878b3713 100644 --- a/frontend/src/components/organizations/SubscriptionCard.tsx +++ b/frontend/src/components/organizations/SubscriptionCard.tsx @@ -6,10 +6,10 @@ import { formatDate } from "../../util.ts"; import { useOrgRole } from "../../hooks/useOrganizations.ts"; export default function SubscriptionCard() { - const { subscription, navigateToSales, navigateToCustomerPortal } = useSubscription(); + const { currentSubscription, navigateToSales, navigateToCustomerPortal } = useSubscription(); const { isAdmin } = useOrgRole(); - if (!subscription) { + if (!currentSubscription) { return null; } @@ -77,7 +77,7 @@ export default function SubscriptionCard() { Your subscription - {details(subscription)} + {details(currentSubscription)} ); diff --git a/frontend/src/components/projects/NewProject.tsx b/frontend/src/components/projects/NewProject.tsx index de71919b..939f0627 100644 --- a/frontend/src/components/projects/NewProject.tsx +++ b/frontend/src/components/projects/NewProject.tsx @@ -1,12 +1,16 @@ -import { Button, Group, Modal, Stack, TextInput } from "@mantine/core"; +import { Button, Group, Modal, Slider, Stack, TextInput } from "@mantine/core"; import { useForm } from "@mantine/form"; import { useOrganizations } from "../../hooks/useOrganizations.ts"; import { useRemails } from "../../hooks/useRemails.ts"; import { notifications } from "@mantine/notifications"; import { errorNotification } from "../../notify.tsx"; +import InfoTooltip from "../InfoTooltip.tsx"; +import { MAX_RETENTION } from "./ProjectSettings.tsx"; +import { useSubscription } from "../../hooks/useSubscription.ts"; interface FormValues { name: string; + retention_period_days: number; } interface NewProjectProps { @@ -16,11 +20,13 @@ interface NewProjectProps { export function NewProject({ opened, close }: NewProjectProps) { const { currentOrganization } = useOrganizations(); + const { currentProduct } = useSubscription(); const { navigate, dispatch } = useRemails(); const form = useForm({ initialValues: { name: "", + retention_period_days: 1, }, validate: { name: (value) => (value.length < 3 ? "Name must have at least 3 letters" : null), @@ -65,11 +71,13 @@ export function NewProject({ opened, close }: NewProjectProps) { }); }; + const max_retention = currentProduct ? MAX_RETENTION[currentProduct] : 1; + return ( <>
- + form.setFieldValue("name", event.currentTarget.value)} /> - - - - - + + + Email retention period (max. {max_retention} day){" "} + + + (value == 1 ? "1 day" : `${value} days`)} + domain={[0, 30]} + min={1} + max={max_retention} + value={form.values.retention_period_days} + onChange={(value) => form.setFieldValue("retention_period_days", value)} + marks={[ + { value: 1, label: "1 day" }, + { value: 7, label: "7 days" }, + { value: 14, label: "14 days" }, + { value: 30, label: "30 days" }, + ]} + /> + + + + + + +
diff --git a/frontend/src/components/projects/ProjectSettings.tsx b/frontend/src/components/projects/ProjectSettings.tsx index 3e8c7ff0..d90e8f69 100644 --- a/frontend/src/components/projects/ProjectSettings.tsx +++ b/frontend/src/components/projects/ProjectSettings.tsx @@ -1,6 +1,6 @@ import { useForm } from "@mantine/form"; -import { Group, List, Stack, Text, TextInput } from "@mantine/core"; -import { Project } from "../../types.ts"; +import { Group, List, Slider, Stack, Text, TextInput } from "@mantine/core"; +import { ProductIdentifier, Project } from "../../types.ts"; import { modals } from "@mantine/modals"; import { notifications } from "@mantine/notifications"; import { IconTrash } from "@tabler/icons-react"; @@ -11,21 +11,39 @@ import { Loader } from "../../Loader.tsx"; import { useDomains } from "../../hooks/useDomains.ts"; import { errorNotification } from "../../notify.tsx"; import { MaintainerButton } from "../RoleButtons.tsx"; +import InfoTooltip from "../InfoTooltip.tsx"; +import { useSubscription } from "../../hooks/useSubscription.ts"; interface FormValues { name: string; + retention_period_days: number; } +// Values should match `max_retention_period` in `src/moneybird/model.rs` +export const MAX_RETENTION: Record = { + "RMLS-FREE": 1, + "RMLS-TINY-MONTHLY": 3, + "RMLS-SMALL-MONTHLY": 7, + "RMLS-MEDIUM-MONTHLY": 14, + "RMLS-LARGE-MONTHLY": 30, + "RMLS-TINY-YEARLY": 3, + "RMLS-SMALL-YEARLY": 7, + "RMLS-MEDIUM-YEARLY": 14, + "RMLS-LARGE-YEARLY": 30, +}; + export default function ProjectSettings() { const { dispatch, navigate } = useRemails(); const { currentOrganization } = useOrganizations(); + const { currentProduct } = useSubscription(); const { currentProject } = useProjects(); const { domains } = useDomains(); const form = useForm({ initialValues: { name: currentProject?.name || "", + retention_period_days: currentProject?.retention_period_days || 1, }, validate: { name: (value) => { @@ -119,11 +137,13 @@ export default function ProjectSettings() { form.resetDirty(); }; + const max_retention = currentProduct ? MAX_RETENTION[currentProduct] : 1; + return ( <>

Project Settings

- + form.setFieldValue("name", event.currentTarget.value)} /> - + + + Email retention period (max. {max_retention} day){" "} + + + (value == 1 ? "1 day" : `${value} days`)} + domain={[0, 30]} + min={1} + max={max_retention} + value={form.values.retention_period_days} + onChange={(value) => form.setFieldValue("retention_period_days", value)} + marks={[ + { value: 1, label: "1 day" }, + { value: 7, label: "7 days" }, + { value: 14, label: "14 days" }, + { value: 30, label: "30 days" }, + ]} + /> + + + } variant="outline" diff --git a/frontend/src/components/statistics/StatCard.tsx b/frontend/src/components/statistics/StatCard.tsx index b49a18bb..2f432cb1 100644 --- a/frontend/src/components/statistics/StatCard.tsx +++ b/frontend/src/components/statistics/StatCard.tsx @@ -1,6 +1,6 @@ -import { Card, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; -import { IconInfoCircle } from "@tabler/icons-react"; +import { Card, Center, Group, Stack, Text } from "@mantine/core"; import { ReactNode } from "react"; +import InfoTooltip from "../InfoTooltip"; type StatCardProps = { title: ReactNode; @@ -15,13 +15,7 @@ export default function StatCard({ title, info, children, footer }: StatCardProp {title} - {info && ( - - - - - - )} + {info && }
{children}
{footer && {footer}} diff --git a/frontend/src/hooks/useSubscription.ts b/frontend/src/hooks/useSubscription.ts index 341efe1b..2790f651 100644 --- a/frontend/src/hooks/useSubscription.ts +++ b/frontend/src/hooks/useSubscription.ts @@ -70,8 +70,12 @@ export function useSubscription() { navigate(routerState.name, { force: "reload-orgs" }); }; + const currentSubscription = currentOrganization?.current_subscription; + const currentProduct = currentSubscription?.status == "active" ? currentSubscription.product : null; + return { - subscription: currentOrganization?.current_subscription, + currentSubscription, + currentProduct, navigateToSales, reloadSubscription, navigateToCustomerPortal, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 89e26b25..cd9833a1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -275,6 +275,7 @@ export interface Organization { export interface Project { id: string; name: string; + retention_period_days: number; created_at: string; updated_at: string; } diff --git a/migrations/20260204151229_configurable_retention.sql b/migrations/20260204151229_configurable_retention.sql new file mode 100644 index 00000000..79251a81 --- /dev/null +++ b/migrations/20260204151229_configurable_retention.sql @@ -0,0 +1,2 @@ +ALTER TABLE projects +ALTER COLUMN retention_period_days DROP DEFAULT; diff --git a/src/api/api_keys.rs b/src/api/api_keys.rs index 1c4cc51e..4b299ce9 100644 --- a/src/api/api_keys.rs +++ b/src/api/api_keys.rs @@ -423,7 +423,7 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::OK); let messages: Vec = deserialize_body(response.into_body()).await; - assert_eq!(messages.len(), 8); + assert_eq!(messages.len(), 9); // get a specific message let message_1 = "e165562a-fb6d-423b-b318-fd26f4610634".parse().unwrap(); diff --git a/src/api/messages.rs b/src/api/messages.rs index aac1f8c5..07258585 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -458,7 +458,7 @@ mod tests { }, bus::client::BusMessage, handler::dns::DnsResolver, - models::{MessageStatus, OrganizationRepository, Role, Statistics}, + models::{MessageStatus, NewProject, OrganizationRepository, Role, Statistics}, periodically::Periodically, test::TestProjects, }; @@ -493,7 +493,7 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::OK); let messages: Vec = deserialize_body(response.into_body()).await; - let messages_in_fixture = 8; + let messages_in_fixture = 9; assert_eq!(messages.len(), messages_in_fixture); // filter by project @@ -1066,8 +1066,8 @@ mod tests { let user_1 = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 let server = TestServer::new(pool.clone(), Some(user_1)).await; - let org_1 = TestProjects::Org1Project2.org_id(); - let message_id = "120dd3eb-5239-4da0-9503-ed72d3850dcd".parse().unwrap(); // message out of retention period + let (org_1, proj_2) = TestProjects::Org1Project2.get_ids(); + let message_id = "54332c7c-fe0e-4b91-acf7-cf4e5f261488".parse().unwrap(); // message within project retention period // message data has not yet been removed let response = server @@ -1086,7 +1086,7 @@ mod tests { let mut stats: Statistics = deserialize_body(response.into_body()).await; stats.sort(); - // expired message data will be removed eventually + // clean up expired messages let bus_client = BusClient::new_from_env_var().unwrap(); let periodically = Periodically::new( pool.clone(), @@ -1097,6 +1097,31 @@ mod tests { .unwrap(); periodically.clean_up().await.unwrap(); + // message data has not yet been removed because project 2 has 3 day retention period + let response = server + .get(format!("/api/organizations/{org_1}/emails/{message_id}")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let message: ApiMessage = deserialize_body(response.into_body()).await; + assert_eq!(message.id(), message_id); + + // update project retention period to just 1 day + let response = server + .put( + format!("/api/organizations/{org_1}/projects/{proj_2}"), + serialize_body(NewProject { + name: "Project 2 Organization 1".to_owned(), + retention_period_days: 1, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // clean up expired messages + periodically.clean_up().await.unwrap(); + // message data has been removed and can no longer be retrieved let response = server .get(format!("/api/organizations/{org_1}/emails/{message_id}")) diff --git a/src/api/projects.rs b/src/api/projects.rs index 807a9603..51e8ede3 100644 --- a/src/api/projects.rs +++ b/src/api/projects.rs @@ -65,12 +65,20 @@ pub async fn list_projects( )] pub async fn update_project( State(repo): State, + State(org_repo): State, Path((org_id, proj_id)): Path<(OrganizationId, ProjectId)>, user: Box, Json(update): Json, ) -> ApiResult { user.has_org_write_access(&org_id)?; + let max_retention = org_repo.max_retention_period(org_id).await?; + if update.retention_period_days < 1 || update.retention_period_days > max_retention { + return Err(AppError::BadRequest(format!( + "Retention period must be between 1 and {max_retention}." + ))); + } + let project = repo.update(org_id, proj_id, update).await?; debug!( @@ -109,6 +117,13 @@ pub async fn create_project( )); } + let max_retention = org_repo.max_retention_period(org_id).await?; + if new.retention_period_days < 1 || new.retention_period_days > max_retention { + return Err(AppError::BadRequest(format!( + "Retention period must be between 1 and {max_retention}." + ))); + } + let project = repo.create(new, org_id).await?; info!( @@ -183,6 +198,7 @@ mod tests { format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project".to_string(), + retention_period_days: 1, }), ) .await @@ -190,6 +206,7 @@ mod tests { assert_eq!(response.status(), StatusCode::CREATED); let project: Project = deserialize_body(response.into_body()).await; assert_eq!(project.name, "Test Project"); + assert_eq!(project.retention_period_days, 1); // list projects let response = server @@ -207,6 +224,7 @@ mod tests { format!("/api/organizations/{org_1}/projects/{}", project.id()), serialize_body(&NewProject { name: "Updated Project".to_string(), + retention_period_days: 1, }), ) .await @@ -214,6 +232,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let project: Project = deserialize_body(response.into_body()).await; assert_eq!(project.name, "Updated Project"); + assert_eq!(project.retention_period_days, 1); // list projects let response = server @@ -268,6 +287,7 @@ mod tests { format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project".to_string(), + retention_period_days: 1, }), ) .await @@ -280,6 +300,7 @@ mod tests { format!("/api/organizations/{org_1}/projects/{proj_1}"), serialize_body(&NewProject { name: "Updated Project".to_string(), + retention_period_days: 1, }), ) .await @@ -294,34 +315,35 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + async fn set_subscription(pool: &PgPool, org_id: OrganizationId, sub: SubscriptionStatus) { + sqlx::query!( + r#" + UPDATE organizations + SET current_subscription = $2 + WHERE id = $1 + "#, + *org_id, + serde_json::to_value(&sub).unwrap() + ) + .execute(pool) + .await + .unwrap(); + } + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] async fn test_project_creation_limit(pool: PgPool) { let user_a = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 let org_1: OrganizationId = "44729d9f-a7dc-4226-b412-36a7537f5176".parse().unwrap(); let server = TestServer::new(pool.clone(), Some(user_a)).await; - let set_subscription = async |sub: SubscriptionStatus| { - sqlx::query!( - r#" - UPDATE organizations - SET current_subscription = $2 - WHERE id = $1 - "#, - *org_1, - serde_json::to_value(&sub).unwrap() - ) - .execute(&pool) - .await - .unwrap(); - }; - // cannot create a project without subscription - set_subscription(SubscriptionStatus::None).await; + set_subscription(&pool, org_1, SubscriptionStatus::None).await; let response = server .post( format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project".to_string(), + retention_period_days: 1, }), ) .await @@ -329,16 +351,18 @@ mod tests { assert_eq!(response.status(), StatusCode::CONFLICT); // cannot create a project without subscription - set_subscription(SubscriptionStatus::Active(mock_subscription( - ProductIdentifier::NotSubscribed, - None, - ))) + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription(ProductIdentifier::NotSubscribed, None)), + ) .await; let response = server .post( format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project".to_string(), + retention_period_days: 1, }), ) .await @@ -346,16 +370,21 @@ mod tests { assert_eq!(response.status(), StatusCode::CONFLICT); // cannot create a project with an expired subscription - set_subscription(SubscriptionStatus::Expired(mock_subscription( - ProductIdentifier::RmlsLargeMonthly, - Utc::now().date_naive(), - ))) + set_subscription( + &pool, + org_1, + SubscriptionStatus::Expired(mock_subscription( + ProductIdentifier::RmlsLargeMonthly, + Utc::now().date_naive(), + )), + ) .await; let response = server .post( format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project".to_string(), + retention_period_days: 1, }), ) .await @@ -363,16 +392,18 @@ mod tests { assert_eq!(response.status(), StatusCode::CONFLICT); // can create 1 project with a free subscription - set_subscription(SubscriptionStatus::Active(mock_subscription( - ProductIdentifier::RmlsFree, - None, - ))) + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription(ProductIdentifier::RmlsFree, None)), + ) .await; let response = server .post( format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project 1".to_string(), + retention_period_days: 1, }), ) .await @@ -385,6 +416,7 @@ mod tests { format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: "Test Project 2".to_string(), + retention_period_days: 1, }), ) .await @@ -405,12 +437,18 @@ mod tests { .into_iter() .enumerate() { - set_subscription(SubscriptionStatus::Active(mock_subscription(product, None))).await; + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription(product, None)), + ) + .await; let response = server .post( format!("/api/organizations/{org_1}/projects"), serialize_body(&NewProject { name: format!("Test Project {}", i + 2), + retention_period_days: 3, // all paid subscriptions allow at least 3 day retention }), ) .await @@ -418,4 +456,115 @@ mod tests { assert_eq!(response.status(), StatusCode::CREATED); } } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "api_users")))] + async fn test_project_retention_limit(pool: PgPool) { + let user_a = "9244a050-7d72-451a-9248-4b43d5108235".parse().unwrap(); // is admin of org 1 and 2 + let org_1: OrganizationId = "44729d9f-a7dc-4226-b412-36a7537f5176".parse().unwrap(); + let server = TestServer::new(pool.clone(), Some(user_a)).await; + + // cannot create a project with a longer retention period with a free subscription + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription(ProductIdentifier::RmlsFree, None)), + ) + .await; + let response = server + .post( + format!("/api/organizations/{org_1}/projects"), + serialize_body(&NewProject { + name: "Test Project 1".to_string(), + retention_period_days: 3, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // tiny subscription cannot use longest retention period + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription(ProductIdentifier::RmlsTinyMonthly, None)), + ) + .await; + let response = server + .post( + format!("/api/organizations/{org_1}/projects"), + serialize_body(&NewProject { + name: "Test Project 1".to_string(), + retention_period_days: 30, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // large subscription can use longest retention period + set_subscription( + &pool, + org_1, + SubscriptionStatus::Active(mock_subscription( + ProductIdentifier::RmlsLargeMonthly, + None, + )), + ) + .await; + let response = server + .post( + format!("/api/organizations/{org_1}/projects"), + serialize_body(&NewProject { + name: "Test Project 1".to_string(), + retention_period_days: 30, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let proj: Project = deserialize_body(response.into_body()).await; + assert_eq!(proj.retention_period_days, 30); + let proj_id = proj.id(); + + // large subscription can't create project with more than 30 day retention + let response = server + .post( + format!("/api/organizations/{org_1}/projects"), + serialize_body(&NewProject { + name: "Test Project 1".to_string(), + retention_period_days: 31, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // large subscription can't update project to more than 30 day retention + let response = server + .put( + format!("/api/organizations/{org_1}/projects/{proj_id}"), + serialize_body(&NewProject { + name: "Updated Project".to_string(), + retention_period_days: 31, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // large subscription can update project to lower retention + let response = server + .put( + format!("/api/organizations/{org_1}/projects/{proj_id}"), + serialize_body(&NewProject { + name: "Updated Project".to_string(), + retention_period_days: 7, + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let proj: Project = deserialize_body(response.into_body()).await; + assert_eq!(proj.retention_period_days, 7); + } } diff --git a/src/fixtures/messages.sql b/src/fixtures/messages.sql index 0ee552cd..391fd36d 100644 --- a/src/fixtures/messages.sql +++ b/src/fixtures/messages.sql @@ -91,6 +91,19 @@ VALUES ('e165562a-fb6d-423b-b318-fd26f4610634', 'REMAILS-e165562a-fb6d-423b-b318 3, 3, now() + '-30 days', -- out of retention period 'temp'), + ('54332c7c-fe0e-4b91-acf7-cf4e5f261488', 'REMAILS-54332c7c-fe0e-4b91-acf7-cf4e5f261488@remails.net', + '9442cbbf-9897-4af7-9766-4ac9c1bf49cf', + '44729d9f-a7dc-4226-b412-36a7537f5176', + 'da12d059-d86e-4ac6-803d-d013045f68ff', -- project 2 org 1 + 'failed', + 'email-rejected@test-org-1-project-2.com', + '{"info@recipient1.com", "info@recipient2.com"}', + E'From: "John Doe" \r\nTo: "James Smith" , \r\n\t"Jane Doe" \r\n\r\nSubject: Hi!\r\nMessage-ID: <1879bda126f711bd.c159afd022971e62.dd16fde439b6435a@helium>\r\nDate: Thu, 20 Nov 2025 14:33:48 +0000\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; \r\n\tboundary="1879bda126f749c8_6516e59cef84032b_ca675b09c11e123e"\r\n\r\n\r\n--1879bda126f749c8_6516e59cef84032b_ca675b09c11e123e\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello world!\r\n--1879bda126f749c8_6516e59cef84032b_ca675b09c11e123e\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n

Hello, world!

\r\n--1879bda126f749c8_6516e59cef84032b_ca675b09c11e123e--\r\n'::bytea, + 'null'::jsonb, + CURRENT_TIMESTAMP - INTERVAL '1d', + 3, 3, + now() + '-2 days', -- within project retention period + 'temp'), ('20746f9b-a6ef-4609-a6f3-53ac63df5b9b', 'REMAILS-20746f9b-a6ef-4609-a6f3-53ac63df5b9b@remails.net', '9442cbbf-9897-4af7-9766-4ac9c1bf49cf', '44729d9f-a7dc-4226-b412-36a7537f5176', diff --git a/src/fixtures/projects.sql b/src/fixtures/projects.sql index ed568ad1..832b654d 100644 --- a/src/fixtures/projects.sql +++ b/src/fixtures/projects.sql @@ -1,10 +1,10 @@ -INSERT INTO projects (id, organization_id, name) +INSERT INTO projects (id, organization_id, name, retention_period_days) VALUES ('3ba14adf-4de1-4fb6-8c20-50cc2ded5462', '44729d9f-a7dc-4226-b412-36a7537f5176', - 'Project 1 Organization 1'), + 'Project 1 Organization 1', 1), ('da12d059-d86e-4ac6-803d-d013045f68ff', '44729d9f-a7dc-4226-b412-36a7537f5176', - 'Project 2 Organization 1'), + 'Project 2 Organization 1', 3), ('70ded685-8633-46ef-9062-d9fbad24ae95', '5d55aec5-136a-407c-952f-5348d4398204', - 'Project 1 Organization 2'); \ No newline at end of file + 'Project 1 Organization 2', 1); \ No newline at end of file diff --git a/src/models/message.rs b/src/models/message.rs index 641995b5..edd358ab 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -955,13 +955,15 @@ impl MessageRepository { trace!("Clearing message data from old messages"); let rows = sqlx::query!( r#" - UPDATE messages + UPDATE messages m SET raw_data = '', message_data = NULL, recipients = '{}', delivery_details = '{}' - WHERE created_at < NOW() - INTERVAL '30 days' - AND octet_length(raw_data) > 0; + FROM projects p + WHERE m.project_id = p.id + AND m.created_at < NOW() - (p.retention_period_days * INTERVAL '1 day') + AND octet_length(m.raw_data) > 0; "# ) .execute(&self.pool) diff --git a/src/models/organization.rs b/src/models/organization.rs index 7417aa0c..9055f5f0 100644 --- a/src/models/organization.rs +++ b/src/models/organization.rs @@ -1,5 +1,4 @@ use crate::{ - ProductIdentifier, models::{ApiUserId, Error, Role}, moneybird::{MoneybirdContactId, SubscriptionStatus}, }; @@ -316,24 +315,25 @@ impl OrganizationRepository { .await?; let subscription: SubscriptionStatus = serde_json::from_value(row.current_subscription)?; - let project_limit = match subscription { - SubscriptionStatus::Active(sub) => match sub.product_id() { - ProductIdentifier::NotSubscribed => Some(0i64), - ProductIdentifier::RmlsFree => Some(1), - ProductIdentifier::RmlsTinyMonthly => None, - ProductIdentifier::RmlsSmallMonthly => None, - ProductIdentifier::RmlsMediumMonthly => None, - ProductIdentifier::RmlsLargeMonthly => None, - ProductIdentifier::RmlsTinyYearly => None, - ProductIdentifier::RmlsSmallYearly => None, - ProductIdentifier::RmlsMediumYearly => None, - ProductIdentifier::RmlsLargeYearly => None, - }, - SubscriptionStatus::Expired(_) => Some(0), - SubscriptionStatus::None => Some(0), - }; - - Ok(project_limit.is_none_or(|limit| limit > row.project_count)) + let project_limit = subscription.active_product().project_limit(); + + Ok(project_limit.is_none_or(|limit| i64::from(limit) > row.project_count)) + } + + pub async fn max_retention_period(&self, id: OrganizationId) -> Result { + let row = sqlx::query!( + r#" + SELECT current_subscription + FROM organizations + WHERE id = $1 + "#, + *id, + ) + .fetch_one(&self.pool) + .await?; + + let subscription: SubscriptionStatus = serde_json::from_value(row.current_subscription)?; + Ok(subscription.active_product().max_retention_period()) } pub async fn remove(&self, id: OrganizationId) -> Result { diff --git a/src/models/projects.rs b/src/models/projects.rs index c3a75247..26eff864 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -37,9 +37,9 @@ pub struct Project { id: ProjectId, organization_id: OrganizationId, pub name: String, + pub retention_period_days: i32, created_at: DateTime, updated_at: DateTime, - retention_period_days: i32, } impl Project { @@ -52,6 +52,7 @@ impl Project { #[cfg_attr(test, derive(Serialize))] pub struct NewProject { pub name: String, + pub retention_period_days: i32, } #[derive(Debug, Clone)] @@ -69,15 +70,23 @@ impl ProjectRepository { new: NewProject, organization_id: OrganizationId, ) -> Result { + if new.retention_period_days < 1 || new.retention_period_days > 30 { + return Err(Error::Internal(format!( + "Invalid retention period ({})", + new.retention_period_days + ))); + } + Ok(sqlx::query_as!( Project, r#" - INSERT INTO projects (id, organization_id, name) - VALUES (gen_random_uuid(), $1, $2) + INSERT INTO projects (id, organization_id, name, retention_period_days) + VALUES (gen_random_uuid(), $1, $2, $3) RETURNING * "#, *organization_id, new.name.trim(), + new.retention_period_days, ) .fetch_one(&self.pool) .await?) @@ -101,11 +110,19 @@ impl ProjectRepository { project_id: ProjectId, update: NewProject, ) -> Result { + if update.retention_period_days < 1 || update.retention_period_days > 30 { + return Err(Error::Internal(format!( + "Invalid retention period ({})", + update.retention_period_days + ))); + } + Ok(sqlx::query_as!( Project, r#" UPDATE projects - SET name = $3 + SET name = $3, + retention_period_days = $4 WHERE id = $2 AND organization_id = $1 RETURNING * @@ -113,6 +130,7 @@ impl ProjectRepository { *organization_id, *project_id, update.name.trim(), + update.retention_period_days, ) .fetch_one(&self.pool) .await?) @@ -134,3 +152,100 @@ impl ProjectRepository { .into()) } } + +#[cfg(test)] +mod test { + use crate::test::TestProjects; + + use super::*; + use sqlx::PgPool; + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations")))] + async fn project_lifecycle(db: PgPool) { + let org_1 = TestProjects::Org1Project1.org_id(); + let repo = ProjectRepository::new(db); + + // no projects + assert_eq!(repo.list(org_1).await.unwrap().len(), 0); + + // create project + let project = repo + .create( + NewProject { + name: "New Project".to_owned(), + retention_period_days: 1, + }, + org_1, + ) + .await + .unwrap(); + assert_eq!(project.name, "New Project"); + assert_eq!(project.retention_period_days, 1); + assert_eq!(project.organization_id, org_1); + + // list projects + let projects = repo.list(org_1).await.unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id(), project.id()); + + // update project + let project = repo + .update( + org_1, + project.id(), + NewProject { + name: "Updated Project".to_owned(), + retention_period_days: 3, + }, + ) + .await + .unwrap(); + assert_eq!(project.name, "Updated Project"); + assert_eq!(project.retention_period_days, 3); + assert_eq!(project.organization_id, org_1); + assert_eq!(projects[0].id(), project.id()); + + // remove project + assert_eq!( + repo.remove(project.id(), org_1).await.unwrap(), + project.id() + ); + + // no projects + assert_eq!(repo.list(org_1).await.unwrap().len(), 0); + } + + /// Test that retention period is limitted to a reasonable amount + /// + /// Note that this does not enforce the subscription-based retention limits, + /// these are enforced within the API layer + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations")))] + async fn retention_period_limit(db: PgPool) { + let org_1 = TestProjects::Org1Project1.org_id(); + let repo = ProjectRepository::new(db); + + let mut n = 0; + let mut new_project = |retention_period_days| { + n += 1; + NewProject { + name: format!("Project {n}"), + retention_period_days, + } + }; + + let project = repo.create(new_project(1), org_1).await.unwrap(); + let id = project.id(); + + // 30 days is the maximum allowed retention period + repo.create(new_project(30), org_1).await.unwrap(); + repo.update(org_1, id, new_project(30)).await.unwrap(); + + // >30 days is not allowed because it could cause issues with the statistics tracking message clean up system + repo.create(new_project(31), org_1).await.unwrap_err(); + repo.update(org_1, id, new_project(31)).await.unwrap_err(); + + // 0 days is not allowed because it would risk deleting messages that haven't been attempted to send yet + repo.create(new_project(0), org_1).await.unwrap_err(); + repo.update(org_1, id, new_project(0)).await.unwrap_err(); + } +} diff --git a/src/moneybird/mod.rs b/src/moneybird/mod.rs index c1a24d5c..a1d94d8d 100644 --- a/src/moneybird/mod.rs +++ b/src/moneybird/mod.rs @@ -60,15 +60,6 @@ impl PartialOrd for SubscriptionStatus { } impl SubscriptionStatus { - pub fn quota(&self) -> u32 { - match self { - SubscriptionStatus::Active(Subscription { product, .. }) => product.monthly_quota(), - SubscriptionStatus::Expired(_) | SubscriptionStatus::None => { - ProductIdentifier::NotSubscribed.monthly_quota() - } - } - } - fn subscription_id(&self) -> Option<&SubscriptionId> { match self { SubscriptionStatus::Active(Subscription { @@ -444,12 +435,7 @@ impl MoneyBird { .calculate_quota_reset_datetime(subscription_status) .await?; - let product = match subscription_status { - SubscriptionStatus::Active(Subscription { product, .. }) => product, - SubscriptionStatus::Expired(_) | SubscriptionStatus::None => { - &ProductIdentifier::NotSubscribed - } - }; + let product = subscription_status.active_product(); self.make_user_admin_on_first_subscription(subscription_status, organization_id) .await?; @@ -478,6 +464,49 @@ impl MoneyBird { "Updated subscription information in database" ); + // Lower the project retention based on active subscription + // + // We don't lower expired subscriptions because they might resubscribe and we don't want + // to make them reconfigure their retention limits + if matches!(subscription_status, SubscriptionStatus::Active(_)) { + self.enforce_retention_limits(organization_id, subscription_status) + .await?; + } + + Ok(()) + } + + async fn enforce_retention_limits( + &self, + organization_id: &OrganizationId, + subscription_status: &SubscriptionStatus, + ) -> Result<(), Error> { + let product = subscription_status.active_product(); + + let projects_updated = sqlx::query!( + r#" + UPDATE projects + SET retention_period_days = $2 + WHERE organization_id = $1 + AND retention_period_days > $2; + "#, + **organization_id, + product.max_retention_period(), + ) + .execute(&self.pool) + .await? + .rows_affected(); + + if projects_updated > 0 { + debug!( + organization_id = organization_id.to_string(), + subscription_id = ?subscription_status.subscription_id(), + product = product.to_string(), + "Lowered the retention period to {} for {projects_updated} projects", + product.max_retention_period() + ); + } + Ok(()) } @@ -655,7 +684,7 @@ impl MoneyBird { self.store_subscription_status(&subscription_status, &organization_id) .await?; - let quota = subscription_status.quota(); + let quota = subscription_status.active_product().monthly_quota(); sqlx::query!( r#" @@ -803,7 +832,10 @@ impl MoneyBird { #[cfg(test)] mod test { use super::*; - use crate::models::{OrganizationRepository, Role}; + use crate::{ + models::{OrganizationRepository, ProjectRepository, Role}, + test::TestProjects, + }; use chrono::{Months, NaiveTime}; use std::ops::Add; @@ -1000,4 +1032,39 @@ mod test { assert_eq!(vec![Role::ReadOnly, Role::ReadOnly], roles); } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("organizations", "projects")))] + async fn max_retention_period_enforcement(db: PgPool) { + let moneybird = MoneyBird::new(db.clone()).await.unwrap(); + let organizations = OrganizationRepository::new(db.clone()); + let projects = ProjectRepository::new(db.clone()); + + let (org_1, proj_2_id) = TestProjects::Org1Project2.get_ids(); + + // project 2 starts with 3 day retention period + let org_1_projects = projects.list(org_1).await.unwrap(); + let proj_2 = org_1_projects.iter().find(|p| p.id() == proj_2_id).unwrap(); + assert_eq!(proj_2.retention_period_days, 3); + + // set Free subscription with max. 1 day retention period + moneybird + .store_subscription_status( + &SubscriptionStatus::Active(Subscription { + end_date: None, + product: ProductIdentifier::RmlsFree, + ..Default::default() + }), + &org_1, + ) + .await + .unwrap(); + + let max_retention = organizations.max_retention_period(org_1).await.unwrap(); + assert_eq!(max_retention, 1); + + // project 2 has been reduced to 1 day retention period + let org_1_projects = projects.list(org_1).await.unwrap(); + let proj_2 = org_1_projects.iter().find(|p| p.id() == proj_2_id).unwrap(); + assert_eq!(proj_2.retention_period_days, 1); + } } diff --git a/src/moneybird/model.rs b/src/moneybird/model.rs index 70e86048..9d7bb832 100644 --- a/src/moneybird/model.rs +++ b/src/moneybird/model.rs @@ -172,6 +172,37 @@ impl ProductIdentifier { ProductIdentifier::RmlsLargeYearly => 1_500_000, } } + + pub fn max_retention_period(&self) -> i32 { + // Values should match `MAX_RETENTION` in `frontend/src/components/projects/ProjectSettings.tsx` + match self { + ProductIdentifier::NotSubscribed => 1, + ProductIdentifier::RmlsFree => 1, + ProductIdentifier::RmlsTinyMonthly => 3, + ProductIdentifier::RmlsSmallMonthly => 7, + ProductIdentifier::RmlsMediumMonthly => 14, + ProductIdentifier::RmlsLargeMonthly => 30, + ProductIdentifier::RmlsTinyYearly => 3, + ProductIdentifier::RmlsSmallYearly => 7, + ProductIdentifier::RmlsMediumYearly => 14, + ProductIdentifier::RmlsLargeYearly => 30, + } + } + + pub fn project_limit(&self) -> Option { + match self { + ProductIdentifier::NotSubscribed => Some(0), + ProductIdentifier::RmlsFree => Some(1), + ProductIdentifier::RmlsTinyMonthly => None, + ProductIdentifier::RmlsSmallMonthly => None, + ProductIdentifier::RmlsMediumMonthly => None, + ProductIdentifier::RmlsLargeMonthly => None, + ProductIdentifier::RmlsTinyYearly => None, + ProductIdentifier::RmlsSmallYearly => None, + ProductIdentifier::RmlsMediumYearly => None, + ProductIdentifier::RmlsLargeYearly => None, + } + } } #[derive(Serialize, PartialEq, Debug, Deserialize, Clone, ToSchema)] @@ -185,6 +216,20 @@ pub enum SubscriptionStatus { None, } +impl SubscriptionStatus { + /// Get the active product of this subscription + /// + /// Expired subscriptions count as `NotSubscribed` + pub fn active_product(&self) -> &ProductIdentifier { + match self { + SubscriptionStatus::Active(sub) => sub.product_id(), + SubscriptionStatus::Expired(_) | SubscriptionStatus::None => { + &ProductIdentifier::NotSubscribed + } + } + } +} + #[derive(Deserialize, Serialize, PartialEq, Debug, Clone, ToSchema)] pub struct Subscription> { pub(super) subscription_id: SubscriptionId,