diff --git a/app/atoms/custom-fields.new.ts b/app/atoms/custom-fields.new.ts new file mode 100644 index 000000000..800237b3b --- /dev/null +++ b/app/atoms/custom-fields.new.ts @@ -0,0 +1,9 @@ +import type { ChangeEvent } from "react"; +import { atom } from "jotai"; + +export const titleAtom = atom("Untitled custom field"); +export const updateTitleAtom = atom( + null, + (_get, set, event: ChangeEvent) => + set(titleAtom, event.target.value) +); diff --git a/app/components/assets/custom-fields-inputs.tsx b/app/components/assets/custom-fields-inputs.tsx new file mode 100644 index 000000000..2e0fdccec --- /dev/null +++ b/app/components/assets/custom-fields-inputs.tsx @@ -0,0 +1,83 @@ +import type { CustomField } from "@prisma/client"; +import { Link, useLoaderData, useNavigation } from "@remix-run/react"; +import type { Zorm } from "react-zorm"; +import type { z } from "zod"; +import type { loader } from "~/routes/_layout+/assets.$assetId_.edit"; +import { isFormProcessing } from "~/utils"; +import { zodFieldIsRequired } from "~/utils/zod"; +import FormRow from "../forms/form-row"; +import Input from "../forms/input"; +import { SearchIcon } from "../icons"; +import { Button } from "../shared"; + +export default function AssetCustomFields({ + zo, + schema, +}: { + zo: Zorm>; + schema: z.ZodObject; +}) { + /** Get the custom fields from the loader */ + const { customFields } = useLoaderData(); + + const { customFields: customFieldsValues } = + useLoaderData()?.asset || []; + + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); + + return ( +
+
+

Custom Fields

+ + Manage custom fields + +
+ {customFields.length > 0 ? ( + customFields.map((field: CustomField) => ( + {field.helpText}

: undefined} + className="border-b-0" + required={zodFieldIsRequired(schema.shape[`cf-${field.id}`])} + > + cfv.customFieldId === field.id + )?.value || "" + } + className="w-full" + required={zodFieldIsRequired(schema.shape[`cf-${field.id}`])} + /> +
+ )) + ) : ( +
+
+
+
+ +
+

No active custom fields

+ +
+
+
+ )} +
+ ); +} diff --git a/app/components/assets/export-button.tsx b/app/components/assets/export-button.tsx new file mode 100644 index 000000000..088cf572b --- /dev/null +++ b/app/components/assets/export-button.tsx @@ -0,0 +1,29 @@ +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/assets._index"; +import { PremiumFeatureButton } from "../subscription/premium-feature-button"; + +export const ExportButton = ({ + canExportAssets, +}: { + canExportAssets: boolean; +}) => { + const { totalItems } = useLoaderData(); + return ( + + ); +}; diff --git a/app/components/assets/form.tsx b/app/components/assets/form.tsx index 5ddf006a0..76a292680 100644 --- a/app/components/assets/form.tsx +++ b/app/components/assets/form.tsx @@ -1,5 +1,5 @@ -import type { Asset, Qr } from "@prisma/client"; -import { Form, Link, useNavigation } from "@remix-run/react"; +import type { Asset, CustomField, Qr } from "@prisma/client"; +import { Form, Link, useLoaderData, useNavigation } from "@remix-run/react"; import { useAtom, useAtomValue } from "jotai"; import type { Tag } from "react-tag-autocomplete"; import { useZorm } from "react-zorm"; @@ -7,7 +7,11 @@ import { z } from "zod"; import { updateTitleAtom } from "~/atoms/assets.new"; import { fileErrorAtom, validateFileAtom } from "~/atoms/file"; import { isFormProcessing } from "~/utils"; + +import { mergedSchema } from "~/utils/custom-field-schema"; import { zodFieldIsRequired } from "~/utils/zod"; +import AssetCustomFields from "./custom-fields-inputs"; + import { CategorySelect } from "../category/category-select"; import FormRow from "../forms/form-row"; import Input from "../forms/input"; @@ -47,7 +51,27 @@ export const AssetForm = ({ tags, }: Props) => { const navigation = useNavigation(); - const zo = useZorm("NewQuestionWizardScreen", NewAssetFormSchema); + + const { customFields } = useLoaderData(); + + // console.log(customFields); + + const FormSchema = mergedSchema({ + baseSchema: NewAssetFormSchema, + customFields: customFields.map( + (cf: CustomField) => + cf.active && { + id: cf.id, + name: cf.name, + helpText: cf?.helpText || "", + required: cf.required, + type: cf.type.toLowerCase() as "text" | "number" | "date" | "boolean", + } + ), + }); + + const zo = useZorm("NewQuestionWizardScreen", FormSchema); + const disabled = isFormProcessing(navigation.state); const fileError = useAtomValue(fileErrorAtom); @@ -67,7 +91,7 @@ export const AssetForm = ({ @@ -111,7 +135,7 @@ export const AssetForm = ({

} className="border-b-0 pb-[10px]" - required={zodFieldIsRequired(NewAssetFormSchema.shape.category)} + required={zodFieldIsRequired(FormSchema.shape.category)} > @@ -127,7 +151,7 @@ export const AssetForm = ({

} className="border-b-0 py-[10px]" - required={zodFieldIsRequired(NewAssetFormSchema.shape.tags)} + required={zodFieldIsRequired(FormSchema.shape.tags)} > @@ -144,7 +168,7 @@ export const AssetForm = ({

} className="pt-[10px]" - required={zodFieldIsRequired(NewAssetFormSchema.shape.newLocationId)} + required={zodFieldIsRequired(FormSchema.shape.newLocationId)} > @@ -158,7 +182,8 @@ export const AssetForm = ({ asset’s overview page. You can always change it.

} - required={zodFieldIsRequired(NewAssetFormSchema.shape.description)} + className="border-b-0" + required={zodFieldIsRequired(FormSchema.shape.description)} > -
+ + +
diff --git a/app/components/assets/import-button.tsx b/app/components/assets/import-button.tsx new file mode 100644 index 000000000..39501c212 --- /dev/null +++ b/app/components/assets/import-button.tsx @@ -0,0 +1,20 @@ +import { PremiumFeatureButton } from "../subscription/premium-feature-button"; + +export const ImportButton = ({ + canImportAssets, +}: { + canImportAssets: boolean; +}) => ( + +); diff --git a/app/components/assets/import-content.tsx b/app/components/assets/import-content.tsx new file mode 100644 index 000000000..b009de108 --- /dev/null +++ b/app/components/assets/import-content.tsx @@ -0,0 +1,212 @@ +import type { ChangeEvent } from "react"; +import { useRef, useState } from "react"; +import { useFetcher } from "@remix-run/react"; +import { isFormProcessing } from "~/utils"; +import Input from "../forms/input"; +import { Button } from "../shared"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../shared/modal"; + +export const ImportBackup = () => ( + <> +

Import from backup

+

+ This option allows you to import assets that have been exported from + shelf. It can be used to restore a backup or move assets from one account + to another. +

+
+

This feature comes with some important limitations:

+
    +
  • Assets will be imported with all their relationships
  • +
  • + Assets will not be merged with existing ones. A asset with a new ID will + be created for each row in your CSV export +
  • +
  • + If you have modified the exported file, there is the possibility of the + import failing due to broken data +
  • +
  • + A new QR code will be created for all the imported assets. If you want + to use an existing physical code, you will need to re-link it to a new + asset manually +
  • +
+ + + +); + +export const ImportContent = () => ( + <> +

Import your own content

+

+ Import your own content by placing it in the csv file. Here you can{" "} + {" "} + Some important details about how this works: +

+
+
    +
  • + You must use ; as a delimiter in your csv file +
  • +
  • Each row represents a new asset that will be created
  • +
  • + Columns such as category, location & custodian represent just the + name of the related entry. As an example, if you put the category{" "} + Laptops we will look for an existing category with that name and + link the asset to it. If it doesn't exist, we will create it. +
  • +
  • + Columns such as tags represent the names of a collection of + entries. To assign multiple tags, just seperate their names with comas. + If the tag doesn't exist, we will create it. +
  • +
  • + The content you are importing will NOT be merged with existing + assets. A new asset will be created for each valid row in the sheet. +
  • +
  • + To import custom fields prefix, your column heading with "cf: ". +
  • +
  • + IMPORTANT: The first row of the sheet will be ignored. Use it to + describe the columns. +
  • +
  • + If any of the data in the file is invalid, the whole import will fail +
  • +
+ + +); + +const FileForm = ({ intent }: { intent: string }) => { + const [agreed, setAgreed] = useState<"I AGREE" | "">(""); + const formRef = useRef(null); + const fetcher = useFetcher(); + const disabled = isFormProcessing(fetcher.state) || agreed !== "I AGREE"; + const isSuccessful = fetcher.data?.success; + + /** We use a controlled field for the file, because of the confirmation dialog we have. + * That way we can disabled the confirmation dialog button until a file is selected + */ + const [selectedFile, setSelectedFile] = useState(null); + const handleFileSelect = (event: ChangeEvent) => { + const selectedFile = event?.target?.files?.[0]; + if (selectedFile) { + setSelectedFile(selectedFile); + } + }; + + return ( + + + + + + + + + + + Confirm asset import + + You need to type: "I AGREE" in the field below to accept + the import. By doing this you agree that you have read the + requirements and you understand the limitations and consiquences + of using this feature. + + setAgreed(e.target.value as any)} + placeholder="I AGREE" + pattern="^I AGREE$" // We use a regex to make sure the user types the exact string + required + /> + + {fetcher.data?.error ? ( +
+ {fetcher.data?.error?.message} +

+ {fetcher.data?.error?.details?.code} +

+

+ Please fix your CSV file and try again. If the issue persists, + don't hesitate to get in touch with us. +

+
+ ) : null} + + {fetcher.data?.success ? ( +
+ Success! +

Your assets have been imported.

+
+ ) : null} + + + {isSuccessful ? ( + + ) : ( + <> + + + + + + )} + +
+
+
+ ); +}; diff --git a/app/components/custom-fields/actions-dropdown.tsx b/app/components/custom-fields/actions-dropdown.tsx new file mode 100644 index 000000000..a27e97a16 --- /dev/null +++ b/app/components/custom-fields/actions-dropdown.tsx @@ -0,0 +1,39 @@ +import type { CustomField } from "@prisma/client"; +import { VerticalDotsIcon } from "~/components/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/shared/dropdown"; +import { Button } from "../shared/button"; + +export function ActionsDropdown({ customField }: { customField: CustomField }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/app/components/custom-fields/form.tsx b/app/components/custom-fields/form.tsx new file mode 100644 index 000000000..c145fa352 --- /dev/null +++ b/app/components/custom-fields/form.tsx @@ -0,0 +1,217 @@ +import { useRef } from "react"; +import { CustomFieldType, type CustomField } from "@prisma/client"; +import { Form, useNavigation } from "@remix-run/react"; +import { useAtom } from "jotai"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; +import { updateTitleAtom } from "~/atoms/custom-fields.new"; +import { useOrganizationId } from "~/hooks/use-organization-id"; +import { isFormProcessing } from "~/utils"; +import { zodFieldIsRequired } from "~/utils/zod"; +import FormRow from "../forms/form-row"; +import Input from "../forms/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../forms/select"; +import { Switch } from "../forms/switch"; +import { Button } from "../shared"; +import { Spinner } from "../shared/spinner"; + +export const NewCustomFieldFormSchema = z.object({ + name: z.string().min(2, "Name is required"), + helpText: z + .string() + .optional() + .transform((val) => val || null), // Transforming undefined to fit prismas null constraint + type: z.nativeEnum(CustomFieldType), + required: z + .string() + .optional() + .transform((val) => (val === "on" ? true : false)), + active: z + .string() + .optional() + .transform((val) => (val === "on" ? true : false)), + organizationId: z.string(), +}); + +/** Pass props of the values to be used as default for the form fields */ +interface Props { + name?: CustomField["name"]; + helpText?: CustomField["helpText"]; + required?: CustomField["required"]; + type?: CustomField["type"]; + active?: CustomField["active"]; +} + +const FIELD_TYPE_DESCRIPTION = { + TEXT: "A place to store short information for your asset. For instance: Serial numbers, notes or anything you wish. No input validation. Any text is acceptable.", +}; + +export const CustomFieldForm = ({ + name, + helpText, + required, + type, + active, +}: Props) => { + const navigation = useNavigation(); + const zo = useZorm("NewQuestionWizardScreen", NewCustomFieldFormSchema); + const disabled = isFormProcessing(navigation.state); + const fieldTypes = CustomFieldType; + + const [, updateTitle] = useAtom(updateTitleAtom); + + // keeping text field type by default selected + const selectedFieldTypeRef = useRef("TEXT"); + const organizationId = useOrganizationId(); + + return ( +
+ + + + +
+ + + + +
+

{FIELD_TYPE_DESCRIPTION[selectedFieldTypeRef.current]}

+
+
+
+ +
+ + +
+
+ + +
+ +
+ +

+ Deactivating a field will no longer show it on the asset form and + page +

+
+
+
+ +
+ + This text will function as a help text that is visible when + filling the field +

+ } + required={zodFieldIsRequired(NewCustomFieldFormSchema.shape.helpText)} + > + +
+
+ + {/* hidden field organization Id to get the organization Id on each form submission to link custom fields and its value is loaded using useOrganizationId hook */} + + +
+ +
+
+ ); +}; diff --git a/app/components/errors/index.tsx b/app/components/errors/index.tsx index 04c4d8807..04218f245 100644 --- a/app/components/errors/index.tsx +++ b/app/components/errors/index.tsx @@ -14,7 +14,7 @@ export const ErrorBoundryComponent = ({ }: ErrorContentProps) => { const error = useRouteError(); /** 404 ERROR */ - if (isRouteErrorResponse(error)) + if (isRouteErrorResponse(error)) { switch (error.status) { case 404: return ( @@ -36,22 +36,42 @@ export const ErrorBoundryComponent = ({ showReload={false} /> ); + default: + /** 500 error */ + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return ( + {errorMessage} + ) : message ? ( + message + ) : ( + "Please try again and if the issue persists, contact support" + ) + } + /> + ); } - - /** 500 error */ - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return ( - {errorMessage} - ) : message ? ( - message - ) : ( - "Please try again and if the issue persists, contact support" - ) - } - /> - ); + } else if (error instanceof Error) { + return ( + + ); + } else { + return ( + + ); + } }; diff --git a/app/components/forms/form-row.tsx b/app/components/forms/form-row.tsx index cc28bad0c..cd3cd4b5e 100644 --- a/app/components/forms/form-row.tsx +++ b/app/components/forms/form-row.tsx @@ -37,7 +37,7 @@ export default function FormRow({ {subHeading}
-
{children}
+
{children}
); } diff --git a/app/components/forms/switch.tsx b/app/components/forms/switch.tsx new file mode 100644 index 000000000..46a146c63 --- /dev/null +++ b/app/components/forms/switch.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { tw } from "~/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function Switch({ className, ...props }, ref) { + return ( + + + + ); +}); + +export { Switch }; diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index c032e54be..e1cd0a2fc 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -282,6 +282,28 @@ export function CheckmarkIcon(props: SVGProps) { ); } +/** Alternative checkmark icon with different styling*/ +export function AltCheckmarkIcon(props: SVGProps) { + return ( + + + + + ); +} + /** This one doesnt have a circle around it. Its a clean version */ export function CheckIcon(props: SVGProps) { return ( @@ -876,6 +898,89 @@ export const ArrowLeftIcon = (props: SVGProps) => ( ); +export const InfoIcon = (props: SVGProps) => ( + + + +); + +export const SingleLayerIcon = (props: SVGProps) => ( + + + +); + +export const DoubleLayerIcon = (props: SVGProps) => ( + + + +); + +export const MultiLayerIcon = (props: SVGProps) => ( + + + + + + + + + + +); + export const EyeIcon = (props: SVGProps) => ( , + React.ComponentPropsWithoutRef +>(function HoverCardContent( + { className, align = "center", sideOffset = 4, ...props }, + ref +) { + return ( + + ); +}); + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 66b3427f2..0ce550d15 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -18,6 +18,7 @@ import { TagsIcon, CategoriesIcon, LocationMarkerIcon, + AssetsIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -39,7 +40,8 @@ export type Icon = | "write" | "tag" | "category" - | "location"; + | "location" + | "asset"; type IconsMap = { [key in Icon]: JSX.Element; @@ -64,6 +66,7 @@ export const iconsMap: IconsMap = { tag: , category: , location: , + asset: , }; export default iconsMap; diff --git a/app/components/shared/tooltip.tsx b/app/components/shared/tooltip.tsx index 258cf935a..df71ac9a8 100644 --- a/app/components/shared/tooltip.tsx +++ b/app/components/shared/tooltip.tsx @@ -12,17 +12,18 @@ const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, sideOffset = 6, ...props }, ref) => ( - -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; +>(function TooltipContent({ className, sideOffset = 6, ...props }, ref) { + return ( + + ); +}); export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/app/components/subscription/current-plan-details.tsx b/app/components/subscription/current-plan-details.tsx new file mode 100644 index 000000000..76761c4a3 --- /dev/null +++ b/app/components/subscription/current-plan-details.tsx @@ -0,0 +1,32 @@ +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/settings.subscription"; + +export const CurrentPlanDetails = () => { + const { activeProduct, expiration, activeSubscription } = + useLoaderData(); + + return ( +
+

+ You’re currently using the {activeProduct?.name} version of + Shelf. +

+
+ {activeSubscription?.canceled_at ? ( + <> +

+ Your plan has been canceled and will be active until{" "} + {expiration.date} at {expiration.time}. +

+

You can renew it at any time by going to the customer portal.

+ + ) : ( +

+ Your subscription renews on {expiration.date} at{" "} + {expiration.time} +

+ )} +
+
+ ); +}; diff --git a/app/components/subscription/customer-portal-form.tsx b/app/components/subscription/customer-portal-form.tsx new file mode 100644 index 000000000..0fbac8d77 --- /dev/null +++ b/app/components/subscription/customer-portal-form.tsx @@ -0,0 +1,19 @@ +import { useFetcher } from "@remix-run/react"; +import { isFormProcessing } from "~/utils"; +import { Button } from "../shared"; + +export const CustomerPortalForm = ({ + buttonText = "Go to Customer Portal", +}: { + buttonText?: string; +}) => { + const customerPortalFetcher = useFetcher(); + const isProcessing = isFormProcessing(customerPortalFetcher.state); + return ( + + + + ); +}; diff --git a/app/components/subscription/helpers.ts b/app/components/subscription/helpers.ts new file mode 100644 index 000000000..b09a16cc1 --- /dev/null +++ b/app/components/subscription/helpers.ts @@ -0,0 +1,23 @@ +export const FREE_PLAN = { + id: "free", + product: { + name: "Free", + metadata: { + features: ` + Unlimited Assets, + Chat support, + 3 Custom Fields, + Github Support, + TLS (SSL) Included, + Automatic Upgrades, + Server Maintenance + `, + slogan: "Free forever. No credit card required.", + }, + }, + unit_amount: 0, + currency: "usd", + recurring: { + interval: "month", + }, +}; diff --git a/app/components/subscription/premium-feature-button.tsx b/app/components/subscription/premium-feature-button.tsx new file mode 100644 index 000000000..01f3d3082 --- /dev/null +++ b/app/components/subscription/premium-feature-button.tsx @@ -0,0 +1,61 @@ +import type { ButtonVariant } from "../layout/header/types"; +import type { ButtonProps } from "../shared"; +import { Button } from "../shared"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "../shared/hover-card"; + +export const PremiumFeatureButton = ({ + canUseFeature, + buttonContent = { + title: "Use", + message: "This feature is not available on the free tier of shelf.", + }, + buttonProps, +}: { + canUseFeature: boolean; + buttonContent: { + title: string; + message: string; + }; + buttonProps: ButtonProps; +}) => + canUseFeature ? ( + + ) : ( + + ); + +const HoverMessage = ({ + buttonContent, +}: { + buttonContent: { + title: string; + message: string; + variant?: ButtonVariant; + }; +}) => ( + + + + + +

+ {buttonContent.message} Please consider{" "} + + . +

+
+
+); diff --git a/app/components/subscription/price-cta.tsx b/app/components/subscription/price-cta.tsx new file mode 100644 index 000000000..e8635f34b --- /dev/null +++ b/app/components/subscription/price-cta.tsx @@ -0,0 +1,34 @@ +import { Form } from "@remix-run/react"; +import type Stripe from "stripe"; +import { CustomerPortalForm } from "./customer-portal-form"; +import type { Price } from "./prices"; +import { Button } from "../shared"; + +export const PriceCta = ({ + price, + activeSubscription, +}: { + price: Price; + activeSubscription: Stripe.Subscription | null; +}) => { + if (price.id === "free") return null; + + if (price?.product?.metadata?.shelf_tier === "tier_2") { + return ; + } + + if (activeSubscription) { + return ( + + ); + } + + return ( +
+ + +
+ ); +}; diff --git a/app/components/subscription/prices.tsx b/app/components/subscription/prices.tsx new file mode 100644 index 000000000..4c9475fb4 --- /dev/null +++ b/app/components/subscription/prices.tsx @@ -0,0 +1,142 @@ +import { useLoaderData } from "@remix-run/react"; +import type Stripe from "stripe"; +import type { loader } from "~/routes/_layout+/settings.subscription"; +import { tw } from "~/utils"; +import { shortenIntervalString } from "~/utils/shorten-interval-string"; +import { FREE_PLAN } from "./helpers"; +import { PriceCta } from "./price-cta"; +import { + AltCheckmarkIcon, + DoubleLayerIcon, + MultiLayerIcon, + SingleLayerIcon, +} from "../icons"; + +export type PriceWithProduct = Stripe.Price & { + product: Stripe.Product; +}; + +export const Prices = ({ prices }: { prices: PriceWithProduct[] }) => ( +
+ + {prices.map((price, index) => ( + + ))} +
+); + +export interface Price { + id: string; + product: { + name: string; + metadata: { + features?: string; + slogan?: string; + shelf_tier?: string; + }; + }; + unit_amount: number | null; + currency: string; + recurring?: { + interval: string; + } | null; +} + +const plansIconsMap: { [key: string]: JSX.Element } = { + free: , + tier_1: , + tier_2: , +}; + +export const Price = ({ + price, + previousPlanName, +}: { + price: Price; + previousPlanName?: string; +}) => { + const { activeSubscription } = useLoaderData(); + const activePlan = activeSubscription?.items.data[0]?.plan; + const isFreePlan = price.id != "free"; + const features = price.product.metadata.features?.split(",") || []; + + // icons to be mapped with different plans + + return ( +
+
+
+
+ + {price.product.metadata.shelf_tier + ? plansIconsMap[price.product.metadata.shelf_tier] + : plansIconsMap["free"]} + +
+
+

+ {price.product.name} +

+ {activePlan?.id === price.id || + (!activeSubscription && price.id === "free") ? ( +
+ Current +
+ ) : null} +
+ {price.unit_amount != null ? ( +
+ {(price.unit_amount / 100).toLocaleString("en-US", { + style: "currency", + currency: price.currency, + maximumFractionDigits: 0, + })} + {price.recurring ? ( + /{shortenIntervalString(price.recurring.interval)} + ) : null} +
+ ) : null} +

+ {price.product.metadata.slogan} +

+
+
+
+ +
+ {features ? ( + <> + {isFreePlan ? ( +

+ All {previousPlanName || "Free"} features and ... +

+ ) : null} + +
    + {features.map((feature) => ( +
  • + + + + {feature} +
  • + ))} +
+ + ) : null} +
+ ); +}; diff --git a/app/components/subscription/successful-subscription-modal.tsx b/app/components/subscription/successful-subscription-modal.tsx new file mode 100644 index 000000000..acabbfeb2 --- /dev/null +++ b/app/components/subscription/successful-subscription-modal.tsx @@ -0,0 +1,59 @@ +import { useCallback } from "react"; +import { useLoaderData, useSearchParams } from "@remix-run/react"; +import { AnimatePresence } from "framer-motion"; +import type { loader } from "~/routes/_layout+/settings.subscription"; +import { Button } from "../shared/button"; + +export default function SuccessfulSubscriptionModal() { + const [params, setParams] = useSearchParams(); + const success = params.get("success") || false; + const handleBackdropClose = useCallback( + (e: React.MouseEvent) => { + if (e.target !== e.currentTarget) return; + params.delete("success"); + setParams(params); + }, + [params, setParams] + ); + + const { activeProduct } = useLoaderData(); + + return ( + + {success ? ( +
+ +
+ +
+

+ You are now subscribed! +

+

+ Thank you, all {activeProduct?.name} features are unlocked. +

+
+ +
+
+
+ ) : null} +
+ ); +} diff --git a/app/database/manual-migrations/master-data/seed-tier.server.ts b/app/database/manual-migrations/master-data/seed-tier.server.ts new file mode 100644 index 000000000..5fc158d46 --- /dev/null +++ b/app/database/manual-migrations/master-data/seed-tier.server.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-console */ +import { PrismaClient, TierId } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export async function createTiers() { + return await prisma.tier.createMany({ + data: [ + { id: TierId.free, name: "Free" }, + { id: TierId.tier_1, name: "Plus" }, + { id: TierId.tier_2, name: "Team" }, + ], + }); +} diff --git a/app/database/migrations/20230804145414_adding_customer_id_to_user/migration.sql b/app/database/migrations/20230804145414_adding_customer_id_to_user/migration.sql new file mode 100644 index 000000000..05059b220 --- /dev/null +++ b/app/database/migrations/20230804145414_adding_customer_id_to_user/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "customerId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId"); diff --git a/app/database/migrations/20230809145513_adding_tier_table/migration.sql b/app/database/migrations/20230809145513_adding_tier_table/migration.sql new file mode 100644 index 000000000..ddc5d998e --- /dev/null +++ b/app/database/migrations/20230809145513_adding_tier_table/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "TierId" AS ENUM ('free', 'tier_1', 'tier_2'); + +-- CreateTable +CREATE TABLE "Tier" ( + "id" "TierId" NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tier_pkey" PRIMARY KEY ("id") +); + +-- Seed with some basic tiers. This is based on current setup we have on stripe +INSERT INTO "Tier" ("id", "name", "updatedAt") +VALUES + ('free', 'Free', CURRENT_TIMESTAMP), + ('tier_1', 'Plus', CURRENT_TIMESTAMP), + ('tier_2', 'Team', CURRENT_TIMESTAMP); + +-- Enable RLS +ALTER TABLE "Tier" ENABLE row level security; \ No newline at end of file diff --git a/app/database/migrations/20230809145816_adding_relationship_between_user_and_tier/migration.sql b/app/database/migrations/20230809145816_adding_relationship_between_user_and_tier/migration.sql new file mode 100644 index 000000000..0526d0d4f --- /dev/null +++ b/app/database/migrations/20230809145816_adding_relationship_between_user_and_tier/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "tierId" "TierId" NOT NULL DEFAULT 'free'; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_tierId_fkey" FOREIGN KEY ("tierId") REFERENCES "Tier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/app/database/migrations/20230811130821_adding_tier_limit_table/migration.sql b/app/database/migrations/20230811130821_adding_tier_limit_table/migration.sql new file mode 100644 index 000000000..8447608d9 --- /dev/null +++ b/app/database/migrations/20230811130821_adding_tier_limit_table/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - A unique constraint covering the columns `[tierLimitId]` on the table `Tier` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Tier" ADD COLUMN "tierLimitId" "TierId"; + +-- CreateTable +CREATE TABLE "TierLimit" ( + "id" "TierId" NOT NULL, + "canImportAssets" BOOLEAN NOT NULL DEFAULT false, + "canExportAssets" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TierLimit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tier_tierLimitId_key" ON "Tier"("tierLimitId"); + +-- AddForeignKey +ALTER TABLE "Tier" ADD CONSTRAINT "Tier_tierLimitId_fkey" FOREIGN KEY ("tierLimitId") REFERENCES "TierLimit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + + +-- Enable RLS +ALTER TABLE "TierLimit" ENABLE row level security; diff --git a/app/database/migrations/20230811132813_inserting_tier_limit_master_data/migration.sql b/app/database/migrations/20230811132813_inserting_tier_limit_master_data/migration.sql new file mode 100644 index 000000000..bf99e265f --- /dev/null +++ b/app/database/migrations/20230811132813_inserting_tier_limit_master_data/migration.sql @@ -0,0 +1,19 @@ +-- Create default Tier limits +INSERT INTO "TierLimit" ("id", "canImportAssets", "canExportAssets", "updatedAt") +SELECT t.id, + CASE + WHEN t.id = 'free' THEN false + ELSE true + END AS "canImportAssets", + CASE + WHEN t.id = 'free' THEN false + ELSE true + END AS "canExportAssets", + CURRENT_TIMESTAMP AS updatedAt +FROM "Tier" AS t; + +-- Update relationships in Tier table +UPDATE "Tier" +SET "tierLimitId" = t.id +FROM "TierLimit" AS t +WHERE "Tier"."id" = t.id; \ No newline at end of file diff --git a/app/database/migrations/20230828143035_adding_custom_field_model/migration.sql b/app/database/migrations/20230828143035_adding_custom_field_model/migration.sql new file mode 100644 index 000000000..364cf0be8 --- /dev/null +++ b/app/database/migrations/20230828143035_adding_custom_field_model/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "CustomFieldType" AS ENUM ('TEXT'); + +-- AlterTable +ALTER TABLE "TierLimit" ADD COLUMN "maxCustomFields" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "CustomField" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "helpText" TEXT, + "required" BOOLEAN NOT NULL DEFAULT false, + "type" "CustomFieldType" NOT NULL DEFAULT 'TEXT', + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CustomField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CustomField_id_key" ON "CustomField"("id"); + +-- AddForeignKey +ALTER TABLE "CustomField" ADD CONSTRAINT "CustomField_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomField" ADD CONSTRAINT "CustomField_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Enable RLS +ALTER TABLE "CustomField" ENABLE row level security; \ No newline at end of file diff --git a/app/database/migrations/20230828143546_updating_tier_limit_value_of_max_custom_fields/migration.sql b/app/database/migrations/20230828143546_updating_tier_limit_value_of_max_custom_fields/migration.sql new file mode 100644 index 000000000..affecaf14 --- /dev/null +++ b/app/database/migrations/20230828143546_updating_tier_limit_value_of_max_custom_fields/migration.sql @@ -0,0 +1,4 @@ +-- Setting the free to 3 and the tiers to 100 +UPDATE "TierLimit" SET "maxCustomFields" = 3 WHERE "id" = 'free'; +UPDATE "TierLimit" SET "maxCustomFields" = 100 WHERE "id" = 'tier_1'; +UPDATE "TierLimit" SET "maxCustomFields" = 100 WHERE "id" = 'tier_2'; \ No newline at end of file diff --git a/app/database/migrations/20230828145652_adding_asset_custom_field_values_table/migration.sql b/app/database/migrations/20230828145652_adding_asset_custom_field_values_table/migration.sql new file mode 100644 index 000000000..d5070ab44 --- /dev/null +++ b/app/database/migrations/20230828145652_adding_asset_custom_field_values_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "AssetCustomFieldValue" ( + "id" TEXT NOT NULL, + "value" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "customFieldId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AssetCustomFieldValue_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "AssetCustomFieldValue" ADD CONSTRAINT "AssetCustomFieldValue_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssetCustomFieldValue" ADD CONSTRAINT "AssetCustomFieldValue_customFieldId_fkey" FOREIGN KEY ("customFieldId") REFERENCES "CustomField"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Enable RLS +ALTER TABLE "AssetCustomFieldValue" ENABLE row level security; \ No newline at end of file diff --git a/app/database/migrations/20230906120138_adding_active_and_removing_cascade_delete_from_custom_field/migration.sql b/app/database/migrations/20230906120138_adding_active_and_removing_cascade_delete_from_custom_field/migration.sql new file mode 100644 index 000000000..bbc6f9b5e --- /dev/null +++ b/app/database/migrations/20230906120138_adding_active_and_removing_cascade_delete_from_custom_field/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "AssetCustomFieldValue" DROP CONSTRAINT "AssetCustomFieldValue_customFieldId_fkey"; + +-- AlterTable +ALTER TABLE "CustomField" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true; + +-- AddForeignKey +ALTER TABLE "AssetCustomFieldValue" ADD CONSTRAINT "AssetCustomFieldValue_customFieldId_fkey" FOREIGN KEY ("customFieldId") REFERENCES "CustomField"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 5bcb51fcb..eb6c4edaa 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -32,6 +32,9 @@ model User { lastName String? profilePicture String? onboarded Boolean @default(false) + customerId String? @unique // Stripe customer id + tierId TierId @default(free) + tier Tier @relation(fields: [tierId], references: [id]) // Datetime createdAt DateTime @default(now()) @@ -48,6 +51,7 @@ model User { locations Location[] images Image[] organizations Organization[] + customFields CustomField[] @@unique([email, username]) } @@ -74,11 +78,12 @@ model Asset { location Location? @relation(fields: [locationId], references: [id]) locationId String? - custody Custody? - notes Note[] - qrCodes Qr[] - reports ReportFound[] - tags Tag[] + custody Custody? + notes Note[] + qrCodes Qr[] + reports ReportFound[] + tags Tag[] + customFields AssetCustomFieldValue[] assetSearchView AssetSearchView? } @@ -287,10 +292,84 @@ model Organization { members TeamMember[] assets Asset[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + customFields CustomField[] } enum OrganizationType { PERSONAL } + +// Tier Ids are used to identify tiers (products) in Stripe. They must be predictable in our model. +// Each product in stripe has a metadata value called `shelf_tier` which holds the value of the enum +// Add more tiers if needed +enum TierId { + free + tier_1 + tier_2 +} + +// Tiers correspond to Stripe products +model Tier { + id TierId @id // Used to create Stripe product ID + name String // Name coming from Stripe product + subscribers User[] + tierLimitId TierId? @unique + tierLimit TierLimit? @relation(fields: [tierLimitId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TierLimit { + id TierId @id + tier Tier? + canImportAssets Boolean @default(false) + canExportAssets Boolean @default(false) + maxCustomFields Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model CustomField { + id String @id @unique @default(cuid()) + name String + helpText String? + required Boolean @default(false) + active Boolean @default(true) + + type CustomFieldType @default(TEXT) + + // Relationships + organization Organization? @relation(fields: [organizationId], references: [id], onUpdate: Cascade) + organizationId String + + createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + // Datetime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + assetCustomFieldsValues AssetCustomFieldValue[] +} + +enum CustomFieldType { + TEXT +} + +model AssetCustomFieldValue { + id String @id @default(cuid()) + + value String + + // Relationships + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: Cascade) + assetId String + + customField CustomField @relation(fields: [customFieldId], references: [id], onUpdate: Cascade) + customFieldId String + + // Datetime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/app/hooks/use-organization-id.ts b/app/hooks/use-organization-id.ts new file mode 100644 index 000000000..bf0b4d5fc --- /dev/null +++ b/app/hooks/use-organization-id.ts @@ -0,0 +1,13 @@ +import type { Organization } from "@prisma/client"; +import { useMatchesData } from "./use-matches-data"; + +/** + * This base hook is used to access the organizationId from within the _layout route + * @param {string} id The route id + * @returns {JSON|undefined} The router data or undefined if not found + */ +export function useOrganizationId(): Organization["id"] | undefined { + return useMatchesData<{ organizationId: Organization["id"] }>( + "routes/_layout+/_layout" + )?.organizationId; +} diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 4dd04ed1e..323614a91 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -7,8 +7,12 @@ import type { Asset, User, Tag, + Organization, + TeamMember, + CustomField, + AssetCustomFieldValue, } from "@prisma/client"; -import { ErrorCorrection } from "@prisma/client"; +import { AssetStatus, ErrorCorrection } from "@prisma/client"; import type { LoaderArgs } from "@remix-run/node"; import { db } from "~/database"; import { @@ -18,10 +22,14 @@ import { getParamsValues, oneDayFromNow, } from "~/utils"; +import { processCustomFields } from "~/utils/import.server"; import { createSignedUrl, parseFileFormData } from "~/utils/storage.server"; -import { getAllCategories } from "../category"; +import { createCategoriesIfNotExists, getAllCategories } from "../category"; +import { createCustomFieldsIfNotExists } from "../custom-field"; +import { createLocationsIfNotExists } from "../location"; import { getQr } from "../qr"; -import { getAllTags } from "../tag"; +import { createTagsIfNotExists, getAllTags } from "../tag"; +import { createTeamMemberIfNotExists } from "../team-member"; export async function getAsset({ userId, @@ -45,6 +53,24 @@ export async function getAsset({ custodian: true, }, }, + customFields: { + where: { + customField: { + active: true, + }, + }, + include: { + customField: { + select: { + id: true, + name: true, + helpText: true, + required: true, + type: true, + }, + }, + }, + }, }, }); @@ -82,7 +108,8 @@ export async function getAssets({ if (search) { const words = search .split(" ") - .map((w) => w.replace(/[^a-zA-Z0-9\-\_]/g, '')).filter(Boolean) //remove uncommon special character + .map((w) => w.replace(/[^a-zA-Z0-9\-_]/g, "")) + .filter(Boolean) //remove uncommon special character .join(" & "); where.searchVector = { search: words, @@ -151,10 +178,14 @@ export async function createAsset({ locationId, qrId, tags, + custodian, + customFieldsValues, }: Pick & { qrId?: Qr["id"]; locationId?: Location["id"]; tags?: { set: { id: string }[] }; + custodian?: TeamMember["id"]; + customFieldsValues?: ({ id: string; value: string | undefined } | null)[]; }) { /** User connction data */ const user = { @@ -176,14 +207,14 @@ export async function createAsset({ qr && qr.userId === userId && qr.assetId === null ? { connect: { id: qrId } } : { - create: [ - { - version: 0, - errorCorrection: ErrorCorrection["L"], - user, - }, - ], - }; + create: [ + { + version: 0, + errorCorrection: ErrorCorrection["L"], + user, + }, + ], + }; /** Data object we send via prisma to create Asset */ const data = { @@ -224,11 +255,45 @@ export async function createAsset({ }); } + /** If a custodian is passed, create a Custody relation with that asset + * `custodian` represents the id of a {@link TeamMember}. */ + if (custodian) { + Object.assign(data, { + custody: { + create: { + custodian: { + connect: { + id: custodian, + }, + }, + }, + }, + status: AssetStatus.IN_CUSTODY, + }); + } + + /** If custom fields are passed, create them */ + if (customFieldsValues && customFieldsValues.length > 0) { + Object.assign(data, { + /** Custom fields here refers to the values, check the Schema for more info */ + customFields: { + create: customFieldsValues?.map( + (cf: { id: string; value: string | undefined } | null) => + cf !== null && { + value: cf?.value || "", + customFieldId: cf.id, + } + ), + }, + }); + } + return db.asset.create({ data, include: { location: true, user: true, + custody: true, }, }); } @@ -244,6 +309,7 @@ interface UpdateAssetPayload { mainImageExpiration?: Asset["mainImageExpiration"]; tags?: { set: { id: string }[] }; userId?: User["id"]; + customFieldsValues?: { id: string; value: string | undefined }[]; } export async function updateAsset(payload: UpdateAssetPayload) { @@ -258,6 +324,7 @@ export async function updateAsset(payload: UpdateAssetPayload) { newLocationId, currentLocationId, userId, + customFieldsValues: customFieldsValuesFromForm, } = payload; const isChangingLocation = newLocationId && currentLocationId && newLocationId !== currentLocationId; @@ -298,6 +365,42 @@ export async function updateAsset(payload: UpdateAssetPayload) { }); } + /** If custom fields are passed, create/update them */ + if (customFieldsValuesFromForm && customFieldsValuesFromForm.length > 0) { + /** We get the current values. We need this in order to co-relate the correct fields to update as we dont have the id's of the values */ + const currentCustomFieldsValues = await db.assetCustomFieldValue.findMany({ + where: { + assetId: id, + }, + select: { + id: true, + customFieldId: true, + }, + }); + + Object.assign(data, { + customFields: { + upsert: customFieldsValuesFromForm?.map( + (cf: { id: string; value: string | undefined }) => ({ + where: { + id: + currentCustomFieldsValues.find( + (ccfv) => ccfv.customFieldId === cf.id + )?.id || "", + }, + update: { + value: cf?.value || "", + }, + create: { + value: cf?.value || "", + customFieldId: cf.id, + }, + }) + ), + }, + }); + } + const asset = await db.asset.update({ where: { id }, data, @@ -439,10 +542,17 @@ export async function deleteNote({ /** Fetches all related entries required for creating a new asset */ export async function getAllRelatedEntries({ userId, + organizationId, }: { userId: User["id"]; -}): Promise<{ categories: Category[]; tags: Tag[]; locations: Location[] }> { - const [categories, tags, locations] = await db.$transaction([ + organizationId: Organization["id"]; +}): Promise<{ + categories: Category[]; + tags: Tag[]; + locations: Location[]; + customFields: CustomField[]; +}> { + const [categories, tags, locations, customFields] = await db.$transaction([ /** Get the categories */ db.category.findMany({ where: { userId } }), @@ -451,8 +561,13 @@ export async function getAllRelatedEntries({ /** Get the locations */ db.location.findMany({ where: { userId } }), + + /** Get the custom fields */ + db.customField.findMany({ + where: { organizationId, active: { equals: true } }, + }), ]); - return { categories, tags, locations }; + return { categories, tags, locations, customFields }; } export const getPaginatedAndFilterableAssets = async ({ @@ -539,3 +654,384 @@ export const createLocationChangeNote = async ({ assetId, }); }; + +/** Fetches assets with the data needed for exporting to CSV */ +export const fetchAssetsForExport = async ({ + userId, +}: { + userId: User["id"]; +}) => + await db.asset.findMany({ + where: { + userId, + }, + include: { + category: true, + location: true, + notes: true, + custody: { + include: { + custodian: true, + }, + }, + tags: true, + customFields: { + include: { + customField: true, + }, + }, + }, + }); + +export const createAssetsFromContentImport = async ({ + data, + userId, + organizationId, +}: { + data: CreateAssetFromContentImportPayload[]; + userId: User["id"]; + organizationId: Organization["id"]; +}) => { + const categories = await createCategoriesIfNotExists({ + data, + userId, + }); + + const locations = await createLocationsIfNotExists({ + data, + userId, + }); + + const teamMembers = await createTeamMemberIfNotExists({ + data, + organizationId, + }); + + const tags = await createTagsIfNotExists({ + data, + userId, + }); + + const customFields = await createCustomFieldsIfNotExists({ + data, + organizationId, + userId, + }); + + for (const asset of data) { + const assetCustomFieldsValues = Object.entries(asset) + .map(([key, value]) => (key.startsWith("cf:") ? value : null)) + .filter((v) => v !== null); + + await createAsset({ + title: asset.title, + description: asset.description || "", + userId, + categoryId: asset.category ? categories[asset.category] : null, + locationId: asset.location ? locations[asset.location] : undefined, + custodian: asset.custodian ? teamMembers[asset.custodian] : undefined, + tags: + asset.tags.length > 0 + ? { + set: asset.tags + .filter((t) => tags[t]) + .map((t) => ({ id: tags[t] })), + } + : undefined, + customFieldsValues: + assetCustomFieldsValues?.length > 0 + ? assetCustomFieldsValues + .map((v: string) => + customFields[v]?.id + ? { + id: customFields[v].id, + value: v || "", + } + : null + ) + .filter((v) => v !== null) + : undefined, + }); + } +}; + +export interface CreateAssetFromContentImportPayload + extends Record { + title: string; + description?: string; + category?: string; + tags: string[]; + location?: string; + custodian?: string; +} + +export const createAssetsFromBackupImport = async ({ + data, + userId, + organizationId, +}: { + data: CreateAssetFromBackupImportPayload[]; + userId: User["id"]; + organizationId: Organization["id"]; +}) => { + // console.log(data); + + data.map(async (asset) => { + /** Base data from asset */ + const d = { + data: { + title: asset.title, + description: asset.description || null, + mainImage: asset.mainImage || null, + mainImageExpiration: oneDayFromNow(), + userId, + organizationId, + status: asset.status, + createdAt: new Date(asset.createdAt), + updatedAt: new Date(asset.updatedAt), + qrCodes: { + create: [ + { + version: 0, + errorCorrection: ErrorCorrection["L"], + userId, + }, + ], + }, + }, + }; + + /** Category */ + if (asset.category && Object.keys(asset?.category).length > 0) { + const category = asset.category as Category; + + const existingCat = await db.category.findFirst({ + where: { + userId, + name: category.name, + }, + }); + + /** If it doesnt exist, create a new one */ + if (!existingCat) { + const newCat = await db.category.create({ + data: { + name: category.name, + description: category.description || "", + color: category.color, + userId, + createdAt: new Date(category.createdAt), + updatedAt: new Date(category.updatedAt), + }, + }); + /** Add it to the data for creating the asset */ + Object.assign(d.data, { + categoryId: newCat.id, + }); + } else { + /** Add it to the data for creating the asset */ + Object.assign(d.data, { + categoryId: existingCat.id, + }); + } + } + + /** Location */ + if (asset.location && Object.keys(asset?.location).length > 0) { + const location = asset.location as Location; + + const existingLoc = await db.location.findFirst({ + where: { + userId, + name: location.name, + }, + }); + + /** If it doesnt exist, create a new one */ + if (!existingLoc) { + const newLoc = await db.location.create({ + data: { + name: location.name, + description: location.description || "", + address: location.address || "", + userId, + createdAt: new Date(location.createdAt), + updatedAt: new Date(location.updatedAt), + }, + }); + /** Add it to the data for creating the asset */ + Object.assign(d.data, { + locationId: newLoc.id, + }); + } else { + /** Add it to the data for creating the asset */ + Object.assign(d.data, { + locationId: existingLoc.id, + }); + } + } + + /** Custody */ + if (asset.custody && Object.keys(asset?.custody).length > 0) { + const { custodian } = asset.custody; + + const existingCustodian = await db.teamMember.findFirst({ + where: { + organizations: { + some: { + id: organizationId, + }, + }, + name: custodian.name, + }, + }); + + if (!existingCustodian) { + const newCustodian = await db.teamMember.create({ + data: { + name: custodian.name, + organizations: { + connect: { + id: organizationId, + }, + }, + createdAt: new Date(custodian.createdAt), + updatedAt: new Date(custodian.updatedAt), + }, + }); + + Object.assign(d.data, { + custody: { + create: { + teamMemberId: newCustodian.id, + }, + }, + }); + } else { + Object.assign(d.data, { + custody: { + create: { + teamMemberId: existingCustodian.id, + }, + }, + }); + } + } + + /** Tags */ + if (asset.tags && asset.tags.length > 0) { + const tagsNames = asset.tags.map((t) => t.name); + // now we loop through the categories and check if they exist + let tags: Record = {}; + for (const tag of tagsNames) { + const existingTag = await db.tag.findFirst({ + where: { + name: tag, + userId, + }, + }); + + if (!existingTag) { + // if the tag doesn't exist, we create a new one + const newTag = await db.tag.create({ + data: { + name: tag as string, + user: { + connect: { + id: userId, + }, + }, + }, + }); + tags[tag] = newTag.id; + } else { + // if the tag exists, we just update the id + tags[tag] = existingTag.id; + } + } + + Object.assign(d.data, { + tags: + asset.tags.length > 0 + ? { + connect: asset.tags.map((tag) => ({ id: tags[tag.name] })), + } + : undefined, + }); + } + + /** Custom fields */ + if (asset.customFields && asset.customFields.length > 0) { + /** we need to check if custom fields exist and create them + * Then we need to also create the values for the asset.customFields + */ + + const cfIds = await processCustomFields({ + asset, + organizationId, + userId, + }); + + Object.assign(d.data, { + customFields: { + create: asset.customFields.map((cf) => ({ + value: cf.value, + customFieldId: cfIds[cf.customField.name], + })), + }, + }); + } + + /** Create the Asset */ + const { id: assetId } = await db.asset.create(d); + + /** Create notes */ + if (asset?.notes?.length > 0) { + await db.note.createMany({ + data: asset.notes.map((note: Note) => ({ + content: note.content, + type: note.type, + assetId, + userId, + createdAt: new Date(note.createdAt), + updatedAt: new Date(note.updatedAt), + })), + }); + } + }); +}; + +export interface CreateAssetFromBackupImportPayload + extends Record { + id: string; + title: string; + description?: string; + category: + | { + id: string; + name: string; + description: string; + color: string; + createdAt: string; + updatedAt: string; + userId: string; + } + | {}; + tags: { + name: string; + }[]; + location: + | { + name: string; + description?: string; + address?: string; + createdAt: string; + updatedAt: string; + } + | {}; + customFields: AssetCustomFieldsValuesWithFields[]; +} + +export type AssetCustomFieldsValuesWithFields = AssetCustomFieldValue & { + customField: CustomField; +}; diff --git a/app/modules/category/service.server.ts b/app/modules/category/service.server.ts index 78b8bdd6d..bf9b025f1 100644 --- a/app/modules/category/service.server.ts +++ b/app/modules/category/service.server.ts @@ -1,5 +1,7 @@ import type { Category, Prisma, User } from "@prisma/client"; import { db } from "~/database"; +import { getRandomColor } from "~/utils"; +import type { CreateAssetFromContentImportPayload } from "../asset"; export async function createCategory({ name, @@ -82,29 +84,70 @@ export async function getAllCategories({ userId }: { userId: User["id"] }) { return await db.category.findMany({ where: { userId } }); } -export async function getCategory({ id }: Pick){ +export async function createCategoriesIfNotExists({ + data, + userId, +}: { + data: CreateAssetFromContentImportPayload[]; + userId: User["id"]; +}): Promise> { + // first we get all the categories from the assets and make then into an object where the category is the key and the value is an empty string + const categories = new Map( + data + .filter((asset) => asset.category !== "") + .map((asset) => [asset.category, ""]) + ); + + // now we loop through the categories and check if they exist + for (const [category, _] of categories) { + const existingCategory = await db.category.findFirst({ + where: { name: category, userId }, + }); + + if (!existingCategory) { + // if the category doesn't exist, we create a new one + const newCategory = await db.category.create({ + data: { + name: (category as string).trim(), + color: getRandomColor(), + user: { + connect: { + id: userId, + }, + }, + }, + }); + categories.set(category, newCategory.id); + } else { + // if the category exists, we just update the id + categories.set(category, existingCategory.id); + } + } + + return Object.fromEntries(Array.from(categories)); +} +export async function getCategory({ id }: Pick) { return db.category.findUnique({ - where: { - id - } - }) + where: { + id, + }, + }); } export async function updateCategory({ id, name, description, - color -}: Pick -) { + color, +}: Pick) { return db.category.update({ where: { - id + id, }, data: { name, description, - color + color, }, }); -} \ No newline at end of file +} diff --git a/app/modules/custom-field/index.ts b/app/modules/custom-field/index.ts new file mode 100644 index 000000000..6b66ae155 --- /dev/null +++ b/app/modules/custom-field/index.ts @@ -0,0 +1 @@ +export * from "./service.server"; diff --git a/app/modules/custom-field/service.server.ts b/app/modules/custom-field/service.server.ts new file mode 100644 index 000000000..a60964259 --- /dev/null +++ b/app/modules/custom-field/service.server.ts @@ -0,0 +1,200 @@ +import { + CustomFieldType, + type CustomField, + type Organization, + type Prisma, + type User, +} from "@prisma/client"; +import { db } from "~/database"; +import type { CreateAssetFromContentImportPayload } from "../asset"; + +export async function createCustomField({ + name, + helpText, + type, + required, + organizationId, + active, + userId, +}: Pick & { + organizationId: Organization["id"]; + userId: User["id"]; +}) { + return db.customField.create({ + data: { + name, + helpText, + type, + required, + active, + organization: { + connect: { + id: organizationId, + }, + }, + createdBy: { + connect: { + id: userId, + }, + }, + }, + }); +} + +export async function getFilteredAndPaginatedCustomFields({ + organizationId, + page = 1, + perPage = 8, + search, +}: { + organizationId: Organization["id"]; + + /** Page number. Starts at 1 */ + page?: number; + + /** Items to be loaded per page */ + perPage?: number; + + search?: string | null; +}) { + const skip = page > 1 ? (page - 1) * perPage : 0; + const take = perPage >= 1 ? perPage : 8; // min 1 and max 25 per page + + /** Default value of where. Takes the items belonging to current user */ + let where: Prisma.CustomFieldWhereInput = { organizationId }; + + /** If the search string exists, add it to the where object */ + if (search) { + where.name = { + contains: search, + mode: "insensitive", + }; + } + + const [customFields, totalCustomFields] = await db.$transaction([ + /** Get the items */ + db.customField.findMany({ + skip, + take, + where, + orderBy: [{ active: "desc" }, { updatedAt: "desc" }], + }), + + /** Count them */ + db.customField.count({ where }), + ]); + + return { customFields, totalCustomFields }; +} + +export async function getCustomField({ + organizationId, + id, +}: Pick & { + organizationId: Organization["id"]; +}) { + const [customField] = await db.$transaction([ + /** Get the item */ + db.customField.findFirst({ + where: { id, organizationId }, + }), + ]); + + return { customField }; +} + +export async function updateCustomField(payload: { + id: CustomField["id"]; + name?: CustomField["name"]; + helpText?: CustomField["helpText"]; + type?: CustomField["type"]; + required?: CustomField["required"]; + active?: CustomField["active"]; +}) { + const { id, name, helpText, type, required, active } = payload; + const data = { + name, + type, + helpText, + required, + active, + }; + + return await db.customField.update({ + where: { id }, + data: data, + }); +} + +export async function createCustomFieldsIfNotExists({ + data, + userId, + organizationId, +}: { + data: CreateAssetFromContentImportPayload[]; + userId: User["id"]; + organizationId: Organization["id"]; +}): Promise> { + // This is the list of all the custom fields keys + // It should have only unique entries + const customFieldsKeys = data + .map((item) => Object.keys(item).filter((k) => k.startsWith("cf:"))) + .flat() + .filter((v, i, a) => a.indexOf(v) === i); + + /** Based on those keys we need to check if custom fields with those names exist */ + const customFields = {}; + + for (const customFieldName of customFieldsKeys) { + const name = customFieldName.replace("cf:", "").trim(); + + const existingCustomField = await db.customField.findFirst({ + where: { + name: name, + organizationId, + }, + }); + + if (!existingCustomField) { + const newCustomField = await createCustomField({ + organizationId, + userId, + name, + type: CustomFieldType.TEXT, + required: false, + helpText: "", + active: true, + }); + // Assign the new custom field to all values associated with the name + for (const item of data) { + if (item.hasOwnProperty(customFieldName)) { + const value = item[customFieldName]; + if (value !== "") { + Object.assign(customFields, { [value]: newCustomField }); + } + } + } + } else { + // Assign the existing custom field to all values associated with the name + for (const item of data) { + if (item.hasOwnProperty(customFieldName)) { + const value = item[customFieldName]; + if (value !== "") { + Object.assign(customFields, { [value]: existingCustomField }); + } + } + } + } + } + + return customFields; +} + +export async function getActiveCustomFields({ userId }: { userId: string }) { + return await db.customField.findMany({ + where: { + userId, + active: true, + }, + }); +} diff --git a/app/modules/location/service.server.ts b/app/modules/location/service.server.ts index 79a5ce5e4..db463561d 100644 --- a/app/modules/location/service.server.ts +++ b/app/modules/location/service.server.ts @@ -1,6 +1,6 @@ import type { Prisma, User, Location } from "@prisma/client"; import { db } from "~/database"; -// import { blobFromBuffer } from "~/utils/blob-from-buffer"; +import type { CreateAssetFromContentImportPayload } from "../asset"; export async function getLocation({ userId, @@ -209,3 +209,45 @@ export async function updateLocation(payload: { data: data, }); } + +export async function createLocationsIfNotExists({ + data, + userId, +}: { + data: CreateAssetFromContentImportPayload[]; + userId: User["id"]; +}): Promise> { + // first we get all the locations from the assets and make then into an object where the category is the key and the value is an empty string + const locations = new Map( + data + .filter((asset) => asset.location !== "") + .map((asset) => [asset.location, ""]) + ); + + // now we loop through the locations and check if they exist + for (const [location, _] of locations) { + const existingCategory = await db.location.findFirst({ + where: { name: location, userId }, + }); + + if (!existingCategory) { + // if the location doesn't exist, we create a new one + const newLocation = await db.location.create({ + data: { + name: (location as string).trim(), + user: { + connect: { + id: userId, + }, + }, + }, + }); + locations.set(location, newLocation.id); + } else { + // if the location exists, we just update the id + locations.set(location, existingCategory.id); + } + } + + return Object.fromEntries(Array.from(locations)); +} diff --git a/app/modules/organization/service.server.ts b/app/modules/organization/service.server.ts index 064626e4d..435b79a3e 100644 --- a/app/modules/organization/service.server.ts +++ b/app/modules/organization/service.server.ts @@ -1,5 +1,5 @@ import { OrganizationType } from "@prisma/client"; -import type { Prisma, Organization } from "@prisma/client"; +import type { Prisma, Organization, User } from "@prisma/client"; import type { LoaderArgs } from "@remix-run/node"; import { db } from "~/database"; import { @@ -140,3 +140,26 @@ export async function getTeamMembers({ return { teamMembers, totalTeamMembers }; } + +export const getOrganizationByUserId = async ({ + userId, + orgType, +}: { + userId: User["id"]; + orgType: OrganizationType; +}) => + await db.organization.findFirst({ + where: { + owner: { + is: { + id: userId, + }, + }, + type: orgType, + }, + select: { + id: true, + name: true, + type: true, + }, + }); diff --git a/app/modules/tag/service.server.ts b/app/modules/tag/service.server.ts index f8bd15e57..b210d5e08 100644 --- a/app/modules/tag/service.server.ts +++ b/app/modules/tag/service.server.ts @@ -1,5 +1,6 @@ -import type { Prisma, Tag, User } from "@prisma/client"; +import type { Prisma, Tag, TeamMember, User } from "@prisma/client"; import { db } from "~/database"; +import type { CreateAssetFromContentImportPayload } from "../asset"; export async function getTags({ userId, @@ -88,27 +89,69 @@ export const buildTagsSet = (tags: string | undefined) => } : { set: [] }; - export async function getTag({ id }: Pick){ - return db.tag.findUnique({ - where: { - id - } - }) - } - - export async function updateTag({ - id, - name, - description - }: Pick - ) { - return db.tag.update({ - where: { - id - }, +export async function createTagsIfNotExists({ + data, + userId, +}: { + data: CreateAssetFromContentImportPayload[]; + userId: User["id"]; +}): Promise> { + const tags = data + .filter(({ tags }) => tags.length > 0) + .reduce((acc: Record, curr) => { + curr.tags.forEach((tag) => tag !== "" && (acc[tag.trim()] = "")); + return acc; + }, {}); + + // now we loop through the categories and check if they exist + for (const tag of Object.keys(tags)) { + const existingTag = await db.tag.findFirst({ + where: { + name: tag, + userId, + }, + }); + + if (!existingTag) { + // if the tag doesn't exist, we create a new one + const newTag = await db.tag.create({ data: { - name, - description + name: tag as string, + user: { + connect: { + id: userId, + }, + }, }, }); - } \ No newline at end of file + tags[tag] = newTag.id; + } else { + // if the tag exists, we just update the id + tags[tag] = existingTag.id; + } + } + return tags; +} +export async function getTag({ id }: Pick) { + return db.tag.findUnique({ + where: { + id, + }, + }); +} + +export async function updateTag({ + id, + name, + description, +}: Pick) { + return db.tag.update({ + where: { + id, + }, + data: { + name, + description, + }, + }); +} diff --git a/app/modules/team-member/index.ts b/app/modules/team-member/index.ts new file mode 100644 index 000000000..6b66ae155 --- /dev/null +++ b/app/modules/team-member/index.ts @@ -0,0 +1 @@ +export * from "./service.server"; diff --git a/app/modules/team-member/service.server.ts b/app/modules/team-member/service.server.ts new file mode 100644 index 000000000..ce2b59801 --- /dev/null +++ b/app/modules/team-member/service.server.ts @@ -0,0 +1,52 @@ +import type { Organization, TeamMember } from "@prisma/client"; +import { db } from "~/database"; +import type { CreateAssetFromContentImportPayload } from "../asset"; + +export async function createTeamMemberIfNotExists({ + data, + organizationId, +}: { + data: CreateAssetFromContentImportPayload[]; + organizationId: Organization["id"]; +}): Promise> { + // first we get all the teamMembers from the assets and make then into an object where the category is the key and the value is an empty string + /** + * Important note: The field in the csv is called "custodian" for making it easy for the user + * However in the app it works a bit different due to how the relationships are + */ + const teamMembers = new Map( + data + .filter((asset) => asset.custodian !== "") + .map((asset) => [asset.custodian, ""]) + ); + + // now we loop through the categories and check if they exist + for (const [teamMember, _] of teamMembers) { + const existingTeamMember = await db.teamMember.findFirst({ + where: { + name: teamMember, + organizations: { some: { id: organizationId } }, + }, + }); + + if (!existingTeamMember) { + // if the teamMember doesn't exist, we create a new one + const newTeamMember = await db.teamMember.create({ + data: { + name: teamMember as string, + organizations: { + connect: { + id: organizationId, + }, + }, + }, + }); + teamMembers.set(teamMember, newTeamMember.id); + } else { + // if the teamMember exists, we just update the id + teamMembers.set(teamMember, existingTeamMember.id); + } + } + + return Object.fromEntries(Array.from(teamMembers)); +} diff --git a/app/modules/tier/index.ts b/app/modules/tier/index.ts new file mode 100644 index 000000000..6b66ae155 --- /dev/null +++ b/app/modules/tier/index.ts @@ -0,0 +1 @@ +export * from "./service.server"; diff --git a/app/modules/tier/service.server.ts b/app/modules/tier/service.server.ts new file mode 100644 index 000000000..ee0fd01c1 --- /dev/null +++ b/app/modules/tier/service.server.ts @@ -0,0 +1,83 @@ +import type { User } from "@prisma/client"; +import { db } from "~/database"; +import { + canCreateMoreCustomFields, + canExportAssets, + canImportAssets, +} from "~/utils/subscription"; +export async function getUserTierLimit(id: User["id"]) { + try { + const { tier } = await db.user.findUniqueOrThrow({ + where: { id }, + select: { + tier: { + include: { tierLimit: true }, + }, + }, + }); + + return tier?.tierLimit; + } catch (cause) { + throw new Error("Something went wrong while fetching user tier limit"); + } +} + +export async function assertUserCanImportAssets({ + userId, +}: { + userId: User["id"]; +}) { + const user = await db.user.findUnique({ + where: { + id: userId, + }, + select: { + tier: { + include: { tierLimit: true }, + }, + organizations: { + select: { + id: true, + type: true, + }, + }, + }, + }); + + if (!canImportAssets(user?.tier?.tierLimit)) { + throw new Error("Your user cannot import assets"); + } + return { user }; +} + +export async function assertUserCanExportAssets({ + userId, +}: { + userId: User["id"]; +}) { + /** Get the tier limit and check if they can export */ + const tierLimit = await getUserTierLimit(userId); + + if (!canExportAssets(tierLimit)) { + throw new Error("Your user cannot export assets"); + } +} + +export const assertUserCanCreateMoreCustomFields = async ({ + userId, +}: { + userId: User["id"]; +}) => { + /** Get the tier limit and check if they can export */ + const tierLimit = await getUserTierLimit(userId); + const canCreateMore = canCreateMoreCustomFields({ + tierLimit, + totalCustomFields: await db.customField.count({ + where: { userId }, + }), + }); + + if (!canCreateMore) { + throw new Error("Your user cannot create more custom fields"); + } +}; diff --git a/app/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 1b15efc1d..6ad8b588f 100644 --- a/app/routes/_layout+/_layout.tsx +++ b/app/routes/_layout+/_layout.tsx @@ -1,4 +1,4 @@ -import { Roles } from "@prisma/client"; +import { OrganizationType, Roles } from "@prisma/client"; import type { LinksFunction, LoaderArgs, @@ -15,26 +15,57 @@ import { userPrefs } from "~/cookies"; import { db } from "~/database"; import { requireAuthSession } from "~/modules/auth"; import styles from "~/styles/layout/index.css"; +import { ENABLE_PREMIUM_FEATURES } from "~/utils"; +import type { CustomerWithSubscriptions } from "~/utils/stripe.server"; +import { + getCustomerActiveSubscription, + getStripeCustomer, +} from "~/utils/stripe.server"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; export const loader: LoaderFunction = async ({ request }: LoaderArgs) => { const authSession = await requireAuthSession(request); - + // @TODO - we need to look into doing a select as we dont want to expose all data always const user = authSession ? await db.user.findUnique({ where: { email: authSession.email.toLowerCase() }, - include: { roles: true }, + include: { + roles: true, + organizations: { + where: { + // This is default for now. Will need to be adjusted when we have more org types and teams functionality is active + type: OrganizationType.PERSONAL, + }, + select: { + id: true, + }, + }, + }, }) : undefined; + let subscription = null; + if (user?.customerId) { + // Get the Stripe customer + const customer = (await getStripeCustomer( + user.customerId + )) as CustomerWithSubscriptions; + /** Find the active subscription for the Stripe customer */ + subscription = getCustomerActiveSubscription({ customer }); + } + const cookieHeader = request.headers.get("Cookie"); const cookie = (await userPrefs.parse(cookieHeader)) || {}; if (!user?.onboarded) { return redirect("onboarding"); } + return json({ user, + organizationId: user?.organizations[0].id, + subscription, + enablePremium: ENABLE_PREMIUM_FEATURES, hideSupportBanner: cookie.hideSupportBanner, minimizedSidebar: cookie.minimizedSidebar, isAdmin: user?.roles.some((role) => role.name === Roles["ADMIN"]), @@ -43,7 +74,6 @@ export const loader: LoaderFunction = async ({ request }: LoaderArgs) => { export default function App() { useCrisp(); - return (
diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 0576bd1de..b9f90298e 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -137,6 +137,10 @@ export const links: LinksFunction = () => [ export default function AssetDetailsPage() { const { asset } = useLoaderData(); + const customFieldsValues = + asset?.customFields?.length > 0 + ? asset.customFields.filter((f) => f?.value && f.value !== "") + : []; const assetIsAvailable = asset.status === "AVAILABLE"; /** Due to some conflict of types between prisma and remix, we need to use the SerializeFrom type * Source: https://github.com/prisma/prisma/discussions/14371 @@ -287,6 +291,31 @@ export default function AssetDetailsPage() { + {/* Here custom fields relates to AssetCustomFieldValue */} + {customFieldsValues?.length > 0 ? ( + <> + + +
    + {customFieldsValues.map((field, index) => ( +
  • + + {field.customField.name} + +
    {field.value}
    +
  • + ))} +
+
+ + ) : null} +
diff --git a/app/routes/_layout+/assets.$assetId_.edit.tsx b/app/routes/_layout+/assets.$assetId_.edit.tsx index 9b9e4e4bf..a55eebd99 100644 --- a/app/routes/_layout+/assets.$assetId_.edit.tsx +++ b/app/routes/_layout+/assets.$assetId_.edit.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { OrganizationType } from "@prisma/client"; import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; @@ -10,6 +11,7 @@ import { ErrorBoundryComponent } from "~/components/errors"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; +import { db } from "~/database"; import { getAllRelatedEntries, getAsset, @@ -18,17 +20,34 @@ import { } from "~/modules/asset"; import { requireAuthSession, commitAuthSession } from "~/modules/auth"; +import { getActiveCustomFields } from "~/modules/custom-field"; +import { getOrganizationByUserId } from "~/modules/organization/service.server"; import { buildTagsSet } from "~/modules/tag"; -import { assertIsPost, getRequiredParam } from "~/utils"; +import { assertIsPost, getRequiredParam, slugify } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { + extractCustomFieldValuesFromResults, + mergedSchema, +} from "~/utils/custom-field-schema"; import { sendNotification } from "~/utils/emitter/send-notification.server"; export async function loader({ request, params }: LoaderArgs) { const { userId } = await requireAuthSession(request); - const { categories, tags, locations } = await getAllRelatedEntries({ + const organization = await getOrganizationByUserId({ userId, + orgType: OrganizationType.PERSONAL, }); + if (!organization) { + throw new Error("Organization not found"); + } + + const { categories, tags, locations, customFields } = + await getAllRelatedEntries({ + userId, + organizationId: organization.id, + }); + const id = getRequiredParam(params, "assetId"); const asset = await getAsset({ userId, id }); @@ -47,6 +66,7 @@ export async function loader({ request, params }: LoaderArgs) { categories, tags, locations, + customFields, }); } @@ -65,9 +85,23 @@ export async function action({ request, params }: ActionArgs) { const id = getRequiredParam(params, "assetId"); const clonedRequest = request.clone(); const formData = await clonedRequest.formData(); - const result = await NewAssetFormSchema.safeParseAsync( - parseFormAny(formData) - ); + + const customFields = await getActiveCustomFields({ + userId: authSession.userId, + }); + + const FormSchema = mergedSchema({ + baseSchema: NewAssetFormSchema, + customFields: customFields.map((cf) => ({ + id: cf.id, + name: slugify(cf.name), + helpText: cf?.helpText || "", + required: cf.required, + type: cf.type.toLowerCase() as "text" | "number" | "date" | "boolean", + })), + }); + const result = await FormSchema.safeParseAsync(parseFormAny(formData)); + const customFieldsValues = extractCustomFieldValuesFromResults({ result }); if (!result.success) { return json( @@ -105,6 +139,7 @@ export async function action({ request, params }: ActionArgs) { newLocationId, currentLocationId, userId: authSession.userId, + customFieldsValues, }); sendNotification({ diff --git a/app/routes/_layout+/assets._index.tsx b/app/routes/_layout+/assets._index.tsx index 08f5fd98d..d1e80dfd8 100644 --- a/app/routes/_layout+/assets._index.tsx +++ b/app/routes/_layout+/assets._index.tsx @@ -1,10 +1,12 @@ import type { Category, Asset, Tag, Custody } from "@prisma/client"; import type { LoaderArgs, V2_MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; -import { useNavigate } from "@remix-run/react"; +import { useLoaderData, useNavigate } from "@remix-run/react"; import { useAtom, useAtomValue } from "jotai"; import { redirect } from "react-router"; import { AssetImage } from "~/components/assets/asset-image"; +import { ExportButton } from "~/components/assets/export-button"; +import { ImportButton } from "~/components/assets/import-button"; import { ChevronRight } from "~/components/icons"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; @@ -22,11 +24,12 @@ import { Badge } from "~/components/shared/badge"; import { Button } from "~/components/shared/button"; import { Tag as TagBadge } from "~/components/shared/tag"; import { Td, Th } from "~/components/table"; +import { db } from "~/database"; import { getPaginatedAndFilterableAssets } from "~/modules/asset"; import { requireAuthSession } from "~/modules/auth"; -import { getUserByID } from "~/modules/user"; import { notFound, userFriendlyAssetStatus } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { canExportAssets, canImportAssets } from "~/utils/subscription"; export interface IndexResponse { /** Page number. Starts at 1 */ @@ -64,11 +67,19 @@ export interface IndexResponse { export async function loader({ request }: LoaderArgs) { const { userId } = await requireAuthSession(request); - const user = await getUserByID(userId); - if (!user) { - return redirect("/login"); - } + const user = await db.user.findUnique({ + where: { + id: userId, + }, + select: { + firstName: true, + tier: { + include: { tierLimit: true }, + }, + }, + }); + const { search, totalAssets, @@ -115,6 +126,8 @@ export async function loader({ request }: LoaderArgs) { next, prev, modelName, + canExportAssets: canExportAssets(user?.tier?.tierLimit), + canImportAssets: canImportAssets(user?.tier?.tierLimit), searchFieldLabel: "Search assets", searchFieldTooltip: { title: "Search your asset database", @@ -129,6 +142,7 @@ export const meta: V2_MetaFunction = ({ data }) => [ export default function AssetIndexPage() { const navigate = useNavigate(); + const { canExportAssets, canImportAssets } = useLoaderData(); const selectedCategories = useAtomValue(selectedCategoriesAtom); const [, clearCategoryFilters] = useAtom(clearCategoryFiltersAtom); @@ -146,11 +160,13 @@ export default function AssetIndexPage() { return ( <>
+ +