diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 554095c6d..92985712e 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -2,6 +2,7 @@ @import "./hooks/toast"; @import "./pages/home/styles"; @import "./pages/users/styles"; +@import "./pages/notifications/styles"; @import "./pages/performers/styles"; @import "./pages/scenes/styles"; @import "./pages/scenes/sceneForm/styles"; diff --git a/frontend/src/components/editCard/EditCard.tsx b/frontend/src/components/editCard/EditCard.tsx index e5601f9f2..52d2c52bc 100644 --- a/frontend/src/components/editCard/EditCard.tsx +++ b/frontend/src/components/editCard/EditCard.tsx @@ -22,9 +22,14 @@ const CLASSNAME = "EditCard"; interface Props { edit: EditFragment; showVotes?: boolean; + hideDiff?: boolean; } -const EditCardComponent: FC = ({ edit, showVotes = false }) => { +const EditCardComponent: FC = ({ + edit, + showVotes = false, + hideDiff = false, +}) => { const title = `${edit.operation.toLowerCase()} ${edit.target_type.toLowerCase()}`; const created = new Date(edit.created); @@ -94,15 +99,21 @@ const EditCardComponent: FC = ({ edit, showVotes = false }) => {
- {creation} - {modifications} - - - {showVotes && } - {comments} - - - + {!hideDiff ? ( + <> + {creation} + {modifications} + + + {showVotes && } + {comments} + + + + + ) : ( + showVotes && + )} ); diff --git a/frontend/src/components/sceneCard/styles.scss b/frontend/src/components/sceneCard/styles.scss index d5a126497..514ad5774 100644 --- a/frontend/src/components/sceneCard/styles.scss +++ b/frontend/src/components/sceneCard/styles.scss @@ -6,12 +6,13 @@ } &-body { - height: 150px; + min-height: 150px; padding: 0; } &-image { align-items: center; + aspect-ratio: 16/9; display: flex; height: 100%; justify-content: center; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index 09c37f8d9..9ccf4df03 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -53,3 +53,4 @@ export const ROUTE_SITES = "/sites"; export const ROUTE_DRAFT = "/drafts/:id"; export const ROUTE_DRAFTS = "/drafts"; export const ROUTE_NOTIFICATIONS = "/notifications"; +export const ROUTE_NOTIFICATION_SUBSCRIPTIONS = "/users/:name/notifications"; diff --git a/frontend/src/graphql/mutations/MarkNotificationsRead.gql b/frontend/src/graphql/mutations/MarkNotificationsRead.gql new file mode 100644 index 000000000..59323fcd2 --- /dev/null +++ b/frontend/src/graphql/mutations/MarkNotificationsRead.gql @@ -0,0 +1,3 @@ +mutation MarkNotificationsRead { + markNotificationsRead +} diff --git a/frontend/src/graphql/mutations/UpdateNotificationSubscriptions.gql b/frontend/src/graphql/mutations/UpdateNotificationSubscriptions.gql new file mode 100644 index 000000000..e7f4bc345 --- /dev/null +++ b/frontend/src/graphql/mutations/UpdateNotificationSubscriptions.gql @@ -0,0 +1,3 @@ +mutation UpdateNotificationSubscriptions($subscriptions: [NotificationEnum!]!) { + updateNotificationSubscriptions(subscriptions: $subscriptions) +} diff --git a/frontend/src/graphql/mutations/index.ts b/frontend/src/graphql/mutations/index.ts index 3d4b26ccc..5eac0a7e5 100644 --- a/frontend/src/graphql/mutations/index.ts +++ b/frontend/src/graphql/mutations/index.ts @@ -132,6 +132,10 @@ import { ConfirmChangeEmailDocument, RequestChangeEmailDocument, RequestChangeEmailMutationVariables, + UpdateNotificationSubscriptionsDocument, + UpdateNotificationSubscriptionsMutation, + UpdateNotificationSubscriptionsMutationVariables, + MarkNotificationsReadDocument, } from "../types"; export const useActivateUser = ( @@ -430,3 +434,30 @@ export const useRequestChangeEmail = ( RequestChangeEmailMutationVariables >, ) => useMutation(RequestChangeEmailDocument, options); + +export const useUpdateNotificationSubscriptions = ( + options?: MutationHookOptions< + UpdateNotificationSubscriptionsMutation, + UpdateNotificationSubscriptionsMutationVariables + >, +) => + useMutation(UpdateNotificationSubscriptionsDocument, { + update(cache, { data }) { + if (data?.updateNotificationSubscriptions) { + cache.evict({ + fieldName: "queryNotifications", + }); + } + }, + ...options, + }); + +export const useMarkNotificationsRead = () => + useMutation(MarkNotificationsReadDocument, { + update(cache, { data }) { + if (data?.markNotificationsRead) { + cache.evict({ fieldName: "queryNotifications" }); + cache.evict({ fieldName: "getUnreadNotificationCount" }); + } + }, + }); diff --git a/frontend/src/graphql/queries/User.gql b/frontend/src/graphql/queries/User.gql index 39e339a6e..437555292 100644 --- a/frontend/src/graphql/queries/User.gql +++ b/frontend/src/graphql/queries/User.gql @@ -32,5 +32,6 @@ query User($name: String!) { canceled pending } + notification_subscriptions } } diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index fb46820a0..d61398bc1 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -589,6 +589,8 @@ export type Mutation = { /** Update a pending tag edit */ tagEditUpdate: Edit; tagUpdate?: Maybe; + /** Update notification subscriptions for current user. */ + updateNotificationSubscriptions: Scalars["Boolean"]["output"]; userCreate?: Maybe; userDestroy: Scalars["Boolean"]["output"]; userUpdate?: Maybe; @@ -793,6 +795,10 @@ export type MutationTagUpdateArgs = { input: TagUpdateInput; }; +export type MutationUpdateNotificationSubscriptionsArgs = { + subscriptions: Array; +}; + export type MutationUserCreateArgs = { input: UserCreateInput; }; @@ -834,6 +840,19 @@ export type NotificationData = | FavoriteStudioScene | UpdatedEdit; +export enum NotificationEnum { + COMMENT_COMMENTED_EDIT = "COMMENT_COMMENTED_EDIT", + COMMENT_OWN_EDIT = "COMMENT_OWN_EDIT", + COMMENT_VOTED_EDIT = "COMMENT_VOTED_EDIT", + DOWNVOTE_OWN_EDIT = "DOWNVOTE_OWN_EDIT", + FAILED_OWN_EDIT = "FAILED_OWN_EDIT", + FAVORITE_PERFORMER_EDIT = "FAVORITE_PERFORMER_EDIT", + FAVORITE_PERFORMER_SCENE = "FAVORITE_PERFORMER_SCENE", + FAVORITE_STUDIO_EDIT = "FAVORITE_STUDIO_EDIT", + FAVORITE_STUDIO_SCENE = "FAVORITE_STUDIO_SCENE", + UPDATED_EDIT = "UPDATED_EDIT", +} + export enum OperationEnum { CREATE = "CREATE", DESTROY = "DESTROY", @@ -1390,6 +1409,8 @@ export type QueryExistingSceneResult = { export type QueryNotificationsInput = { page?: Scalars["Int"]["input"]; per_page?: Scalars["Int"]["input"]; + type?: InputMaybe; + unread_only?: InputMaybe; }; export type QueryNotificationsResult = { @@ -1966,6 +1987,7 @@ export type User = { invite_tokens?: Maybe; invited_by?: Maybe; name: Scalars["String"]["output"]; + notification_subscriptions: Array; /** Should not be visible to other users */ roles?: Maybe>; /** Vote counts by type */ @@ -4709,6 +4731,15 @@ export type GrantInviteMutation = { grantInvite: number; }; +export type MarkNotificationsReadMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type MarkNotificationsReadMutation = { + __typename: "Mutation"; + markNotificationsRead: boolean; +}; + export type NewUserMutationVariables = Exact<{ input: NewUserInput; }>; @@ -13259,6 +13290,15 @@ export type UnmatchFingerprintMutation = { unmatchFingerprint: boolean; }; +export type UpdateNotificationSubscriptionsMutationVariables = Exact<{ + subscriptions: Array | NotificationEnum; +}>; + +export type UpdateNotificationSubscriptionsMutation = { + __typename: "Mutation"; + updateNotificationSubscriptions: boolean; +}; + export type UpdateSceneMutationVariables = Exact<{ updateData: SceneUpdateInput; }>; @@ -30422,6 +30462,7 @@ export type UserQuery = { api_key?: string | null; api_calls: number; invite_tokens?: number | null; + notification_subscriptions: Array; invited_by?: { __typename: "User"; id: string; name: string } | null; invite_codes?: Array<{ __typename: "InviteKey"; @@ -37515,6 +37556,28 @@ export const GrantInviteDocument = { }, ], } as unknown as DocumentNode; +export const MarkNotificationsReadDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "MarkNotificationsRead" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "markNotificationsRead" }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + MarkNotificationsReadMutation, + MarkNotificationsReadMutationVariables +>; export const NewUserDocument = { kind: "Document", definitions: [ @@ -50962,6 +51025,60 @@ export const UnmatchFingerprintDocument = { UnmatchFingerprintMutation, UnmatchFingerprintMutationVariables >; +export const UpdateNotificationSubscriptionsDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "UpdateNotificationSubscriptions" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "subscriptions" }, + }, + type: { + kind: "NonNullType", + type: { + kind: "ListType", + type: { + kind: "NonNullType", + type: { + kind: "NamedType", + name: { kind: "Name", value: "NotificationEnum" }, + }, + }, + }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "updateNotificationSubscriptions" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "subscriptions" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "subscriptions" }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpdateNotificationSubscriptionsMutation, + UpdateNotificationSubscriptionsMutationVariables +>; export const UpdateSceneDocument = { kind: "Document", definitions: [ @@ -67334,6 +67451,10 @@ export const UserDocument = { ], }, }, + { + kind: "Field", + name: { kind: "Name", value: "notification_subscriptions" }, + }, ], }, }, diff --git a/frontend/src/pages/notifications/CommentNotification.tsx b/frontend/src/pages/notifications/CommentNotification.tsx new file mode 100644 index 000000000..ccca88c3b --- /dev/null +++ b/frontend/src/pages/notifications/CommentNotification.tsx @@ -0,0 +1,11 @@ +import { FC } from "react"; +import type { CommentNotificationType } from "./types"; +import EditComment from "src/components/editCard/EditComment"; + +interface Props { + notification: CommentNotificationType; +} + +export const CommentNotification: FC = ({ notification }) => ( + +); diff --git a/frontend/src/pages/notifications/EditNotification.tsx b/frontend/src/pages/notifications/EditNotification.tsx new file mode 100644 index 000000000..68ef9c906 --- /dev/null +++ b/frontend/src/pages/notifications/EditNotification.tsx @@ -0,0 +1,11 @@ +import { FC } from "react"; +import EditCard from "src/components/editCard"; +import type { EditNotificationType } from "./types"; + +interface Props { + notification: EditNotificationType; +} + +export const EditNotification: FC = ({ notification }) => { + return ; +}; diff --git a/frontend/src/pages/notifications/Notification.tsx b/frontend/src/pages/notifications/Notification.tsx index 41915e60a..2cd92faea 100644 --- a/frontend/src/pages/notifications/Notification.tsx +++ b/frontend/src/pages/notifications/Notification.tsx @@ -1,52 +1,116 @@ import React from "react"; -import { NotificationsQuery } from "src/graphql"; -import SceneCard from "src/components/sceneCard"; -import EditCard from "src/components/editCard"; - -type NotificationType = - NotificationsQuery["queryNotifications"]["notifications"][number]; +import { faEnvelope, faEnvelopeOpen } from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "src/components/fragments"; +import { + NotificationType, + isSceneNotification, + isEditNotification, + isCommentNotification, +} from "./types"; +import { CommentNotification } from "./CommentNotification"; +import { SceneNotification } from "./sceneNotification"; +import { EditNotification } from "./EditNotification"; +import { editHref } from "src/utils"; +import { Link } from "react-router-dom"; interface Props { notification: NotificationType; } -const headers = { - FavoritePerformerScene: "New scene involving a favorite performer", - FavoriteStudioScene: "New scene from a favorite studio", - FavoritePerformerEdit: "New edit involving a favorite performer", - FavoriteStudioEdit: "New edit involving a favorite studio", - DownvoteOwnEdit: "Your edit was downvoted", - FailedOwnEdit: "Your edit failed", - UpdatedEdit: "An edit you voted on was updated", - CommentOwnEdit: "A user commented on your edit", - CommentCommentedEdit: "A user commented on an edit you've commented on", +const NotificationBody = ({ + notification, +}: { + notification: NotificationType; +}) => { + if (isCommentNotification(notification)) + return ; + if (isEditNotification(notification)) + return ; + if (isSceneNotification(notification)) + return ; }; -const renderNotificationBody = (notification: NotificationType) => { - switch (notification.data.__typename) { - case "FavoritePerformerScene": - case "FavoriteStudioScene": - return ( - <> -

{headers[notification.data.__typename]}

- - +const NotificationHeader = ({ + notification, +}: { + notification: NotificationType; +}) => { + const headerText = () => { + if (isCommentNotification(notification)) { + const editLink = ( + + edit + ); - case "FavoritePerformerEdit": - case "FavoriteStudioEdit": - case "DownvoteOwnEdit": - case "FailedOwnEdit": - case "UpdatedEdit": - return ; - case "CommentOwnEdit": - case "CommentCommentedEdit": - case "CommentVotedEdit": - return "comment"; - } + if (notification.data.__typename === "CommentCommentedEdit") + return ( + + {notification.data.comment.user?.name} commented on an{" "} + {editLink} + {"you've commented on."} + + ); + if (notification.data.__typename === "CommentOwnEdit") + return ( + + {notification.data.comment.user?.name} commented on your{" "} + {editLink}. + + ); + if (notification.data.__typename === "CommentVotedEdit") + return ( + + {notification.data.comment.user?.name} commented on an{" "} + {editLink} + {" you've voted on."} + + ); + } + if (isEditNotification(notification)) { + if (notification.data.__typename === "DownvoteOwnEdit") + return `A user voted no on your edit.`; + if (notification.data.__typename === "FailedOwnEdit") + return `Your edit has failed.`; + if (notification.data.__typename === "UpdatedEdit") + return `An edit you've voted on was updated.`; + if (notification.data.__typename === "FavoritePerformerEdit") + return `An edit was created involving a favorited performer.`; + if (notification.data.__typename === "FavoriteStudioEdit") + return `An edit was created involving a favorited studio.`; + } + if (isSceneNotification(notification)) { + if (notification.data.__typename === "FavoriteStudioScene") + return ( + + A new scene from {notification.data.scene.studio?.name} was + submitted. + + ); + if (notification.data.__typename === "FavoritePerformerScene") + return `A new scene involving a favorited performer was submitted.`; + } + }; + + return ( +
+ + {headerText()} +
+ ); }; export const Notification: React.FC = ({ notification }) => { return ( -
{renderNotificationBody(notification)}
+
+ + +
); }; diff --git a/frontend/src/pages/notifications/Notifications.tsx b/frontend/src/pages/notifications/Notifications.tsx index 72af54dcb..c38ca1216 100644 --- a/frontend/src/pages/notifications/Notifications.tsx +++ b/frontend/src/pages/notifications/Notifications.tsx @@ -1,38 +1,134 @@ import { FC } from "react"; -import { useNotifications } from "src/graphql"; -import { usePagination } from "src/hooks"; -import { ErrorMessage } from "src/components/fragments"; +import { Button, Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { + useNotifications, + useMarkNotificationsRead, + NotificationEnum, + useUnreadNotificationsCount, +} from "src/graphql"; +import { useCurrentUser, useQueryParams, usePagination } from "src/hooks"; +import { ROUTE_NOTIFICATION_SUBSCRIPTIONS } from "src/constants/route"; +import { userHref, resolveEnum, NotificationType } from "src/utils"; +import { ErrorMessage, Icon } from "src/components/fragments"; import { List } from "src/components/list"; import { Notification } from "./Notification"; const PER_PAGE = 20; const Notifications: FC = () => { + const { user } = useCurrentUser(); const { page, setPage } = usePagination(); + const [params, setParams] = useQueryParams({ + notification: { name: "notification", type: "string", default: "all" }, + unread: { name: "unread", type: "string", default: "false" }, + }); + const notification = resolveEnum( + NotificationEnum, + params.notification, + undefined, + ); + const unread = params.unread === "true"; + + const { data: unreadNotificationsCount } = useUnreadNotificationsCount(); + const [markNotificationsRead, { loading: markingRead }] = + useMarkNotificationsRead(); const { loading, data } = useNotifications({ - input: { page, per_page: PER_PAGE }, + input: { + page, + per_page: PER_PAGE, + unread_only: unread, + type: notification, + }, }); if (loading) return null; if (!loading && !data) return ; + const enumToOptions = (e: Record) => + Object.keys(e).map((key) => ( + + )); + return ( - - {data?.queryNotifications?.notifications?.map((n) => ( - - ))} - + <> +
+

Notifications

+ {user && ( + <> + + + + + + )} +
+ + + Notification Type + + setParams("notification", e.currentTarget.value) + } + value={notification} + style={{ maxWidth: 250 }} + > + + {enumToOptions(NotificationType)} + + + + + Unread Only + + setParams("unread", e.currentTarget.checked.toString()) + } + /> + + + } + loading={loading} + entityName="notifications" + > + {data?.queryNotifications?.notifications?.map((n) => ( + + ))} + + ); }; diff --git a/frontend/src/pages/notifications/sceneNotification.tsx b/frontend/src/pages/notifications/sceneNotification.tsx new file mode 100644 index 000000000..86e79517c --- /dev/null +++ b/frontend/src/pages/notifications/sceneNotification.tsx @@ -0,0 +1,11 @@ +import { FC } from "react"; +import SceneCard from "src/components/sceneCard"; +import type { SceneNotificationType } from "./types"; + +interface Props { + notification: SceneNotificationType; +} + +export const SceneNotification: FC = ({ notification }) => { + return ; +}; diff --git a/frontend/src/pages/notifications/styles.scss b/frontend/src/pages/notifications/styles.scss new file mode 100644 index 000000000..90354f919 --- /dev/null +++ b/frontend/src/pages/notifications/styles.scss @@ -0,0 +1,5 @@ +.notification { + .SceneCard { + width: 300px; + } +} diff --git a/frontend/src/pages/notifications/types.ts b/frontend/src/pages/notifications/types.ts new file mode 100644 index 000000000..ebb5f4f16 --- /dev/null +++ b/frontend/src/pages/notifications/types.ts @@ -0,0 +1,25 @@ +import { NotificationsQuery } from "src/graphql"; + +export type NotificationType = + NotificationsQuery["queryNotifications"]["notifications"][number]; + +type CommentData = Extract; +export type CommentNotificationType = NotificationType & { data: CommentData }; +export const isCommentNotification = ( + notification: NotificationType, +): notification is CommentNotificationType => + (notification.data as CommentData).comment !== undefined; + +type EditData = Extract; +export type EditNotificationType = NotificationType & { data: EditData }; +export const isEditNotification = ( + notification: NotificationType, +): notification is EditNotificationType => + (notification.data as EditData).edit !== undefined; + +type SceneData = Extract; +export type SceneNotificationType = NotificationType & { data: SceneData }; +export const isSceneNotification = ( + notification: NotificationType, +): notification is SceneNotificationType => + (notification.data as SceneData).scene !== undefined; diff --git a/frontend/src/pages/users/UserNotificationPreferences.tsx b/frontend/src/pages/users/UserNotificationPreferences.tsx new file mode 100644 index 000000000..c9f571754 --- /dev/null +++ b/frontend/src/pages/users/UserNotificationPreferences.tsx @@ -0,0 +1,65 @@ +import type { FC } from "react"; +import { Button, Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { ROUTE_NOTIFICATIONS } from "src/constants/route"; +import { + NotificationEnum, + useUpdateNotificationSubscriptions, +} from "src/graphql"; +import { NotificationType, ensureEnum } from "src/utils"; + +interface Props { + user: { + id: string; + notification_subscriptions: NotificationEnum[]; + }; +} + +export const UserNotificationPreferences: FC = ({ user }) => { + const [updateSubscriptions, { loading: submitting }] = + useUpdateNotificationSubscriptions(); + const activeNotifications: string[] = user.notification_subscriptions.map( + (e) => e, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const subscriptions = data + .getAll("subscriptions") + .map((sub) => ensureEnum(NotificationEnum, sub.toString())); + + updateSubscriptions({ variables: { subscriptions } }); + }; + + return ( + <> + +
← Notifications
+ +

Active notification subscriptions

+
+ +
+ {Object.entries(NotificationType).map(([key, value]) => ( + + ))} +
+ + +
+ + + ); +}; diff --git a/frontend/src/pages/users/index.tsx b/frontend/src/pages/users/index.tsx index a20be6eb9..d0428c1c3 100644 --- a/frontend/src/pages/users/index.tsx +++ b/frontend/src/pages/users/index.tsx @@ -14,6 +14,7 @@ import UserEdits from "./UserEdits"; import UserConfirmChangeEmail from "./UserConfirmChangeEmail"; import UserValidateChangeEmail from "./UserValidateChangeEmail"; import UserFingerprints from "./UserFingerprints"; +import { UserNotificationPreferences } from "./UserNotificationPreferences"; const UserLoader: FC = () => { const { name } = useParams<{ name: string }>(); @@ -63,6 +64,19 @@ const UserLoader: FC = () => { path="/change-email" element={} /> + + + <UserNotificationPreferences user={user} /> + </> + ) : ( + <ErrorMessage error="Forbidden" /> + ) + } + /> </Routes> ); }; diff --git a/frontend/src/utils/enum.ts b/frontend/src/utils/enum.ts index 9a3b0b887..d708b8054 100644 --- a/frontend/src/utils/enum.ts +++ b/frontend/src/utils/enum.ts @@ -3,6 +3,7 @@ import { FingerprintAlgorithm, EthnicityEnum, GenderEnum, + NotificationEnum, } from "src/graphql"; export const genderEnum = ( @@ -87,3 +88,22 @@ export const resolveEnum = <T>( (Object.values(enm) as unknown as string[]).includes(value.toUpperCase()) ? (value.toUpperCase() as unknown as T) : defaultValue; + +type NotificationEnumMap = { [key in NotificationEnum]: string }; +export const NotificationType: NotificationEnumMap = { + [NotificationEnum.UPDATED_EDIT]: "Updates to an edit you have voted on.", + [NotificationEnum.COMMENT_OWN_EDIT]: "Comments on one of your edits", + [NotificationEnum.DOWNVOTE_OWN_EDIT]: "Downvotes on one of your edits", + [NotificationEnum.FAILED_OWN_EDIT]: "One of your edits have failed", + [NotificationEnum.COMMENT_COMMENTED_EDIT]: + "Comments on edits you have commented on", + [NotificationEnum.COMMENT_VOTED_EDIT]: "Comments on edits you have voted on", + [NotificationEnum.FAVORITE_PERFORMER_EDIT]: + "An edit to a performer you have favorited, or a scene involving them.", + [NotificationEnum.FAVORITE_STUDIO_EDIT]: + "An edit to a studio you have favorited, or a scene from that studio.", + [NotificationEnum.FAVORITE_STUDIO_SCENE]: + "A new scene from a studio you have favorited.", + [NotificationEnum.FAVORITE_PERFORMER_SCENE]: + "A new scene involving a performer you have favorited.", +}; diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 8431c4a75..ba88335fd 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -189,6 +189,8 @@ type Mutation { """Mark all of the current users notifications as read.""" markNotificationsRead: Boolean! @hasRole(role: READ) + """Update notification subscriptions for current user.""" + updateNotificationSubscriptions(subscriptions: [NotificationEnum!]!): Boolean! @hasRole(role: EDIT) } schema { diff --git a/graphql/schema/types/notifications.graphql b/graphql/schema/types/notifications.graphql index e9d0e3384..f4767d812 100644 --- a/graphql/schema/types/notifications.graphql +++ b/graphql/schema/types/notifications.graphql @@ -4,6 +4,19 @@ type Notification { data: NotificationData! } +enum NotificationEnum { + FAVORITE_PERFORMER_SCENE + FAVORITE_PERFORMER_EDIT + FAVORITE_STUDIO_SCENE + FAVORITE_STUDIO_EDIT + COMMENT_OWN_EDIT + DOWNVOTE_OWN_EDIT + FAILED_OWN_EDIT + COMMENT_COMMENTED_EDIT + COMMENT_VOTED_EDIT + UPDATED_EDIT +} + union NotificationData = | FavoritePerformerScene | FavoritePerformerEdit @@ -59,6 +72,8 @@ type UpdatedEdit { input QueryNotificationsInput { page: Int! = 1 per_page: Int! = 25 + type: NotificationEnum + unread_only: Boolean } type QueryNotificationsResult { diff --git a/graphql/schema/types/user.graphql b/graphql/schema/types/user.graphql index fb12ca9a8..40ae83d71 100644 --- a/graphql/schema/types/user.graphql +++ b/graphql/schema/types/user.graphql @@ -29,6 +29,7 @@ type User { email: String @isUserOwner """Should not be visible to other users""" api_key: String @isUserOwner + notification_subscriptions: [NotificationEnum!]! @isUserOwner """ Vote counts by type """ vote_count: UserVoteCount! diff --git a/pkg/api/resolver_model_user.go b/pkg/api/resolver_model_user.go index 5d302f8fd..0d73e49af 100644 --- a/pkg/api/resolver_model_user.go +++ b/pkg/api/resolver_model_user.go @@ -87,3 +87,8 @@ func (r *userResolver) InviteCodes(ctx context.Context, user *models.User) ([]*m qb := r.getRepoFactory(ctx).Invite() return qb.FindActiveKeysForUser(user.ID, config.GetActivationExpireTime()) } + +func (r *userResolver) NotificationSubscriptions(ctx context.Context, user *models.User) ([]models.NotificationEnum, error) { + qb := r.getRepoFactory(ctx).Joins() + return qb.GetUserNotifications(user.ID) +} diff --git a/pkg/api/resolver_mutation_notifications.go b/pkg/api/resolver_mutation_notifications.go index 1745e8475..746e62cf1 100644 --- a/pkg/api/resolver_mutation_notifications.go +++ b/pkg/api/resolver_mutation_notifications.go @@ -2,8 +2,32 @@ package api import ( "context" + + "github.com/stashapp/stash-box/pkg/models" ) func (r *mutationResolver) MarkNotificationsRead(ctx context.Context) (bool, error) { - return true, nil + user := getCurrentUser(ctx) + fac := r.getRepoFactory(ctx) + err := fac.WithTxn(func() error { + qb := fac.Notification() + return qb.MarkRead(user.ID) + }) + return err == nil, err +} + +func (r *mutationResolver) UpdateNotificationSubscriptions(ctx context.Context, subscriptions []models.NotificationEnum) (bool, error) { + user := getCurrentUser(ctx) + fac := r.getRepoFactory(ctx) + err := fac.WithTxn(func() error { + qb := fac.Joins() + var userNotifications []*models.UserNotification + for _, s := range subscriptions { + userNotification := models.UserNotification{UserID: user.ID, Type: s} + userNotifications = append(userNotifications, &userNotification) + } + return qb.UpdateUserNotifications(user.ID, userNotifications) + }) + + return err == nil, err } diff --git a/pkg/api/resolver_mutation_user.go b/pkg/api/resolver_mutation_user.go index 88a841490..9247725d0 100644 --- a/pkg/api/resolver_mutation_user.go +++ b/pkg/api/resolver_mutation_user.go @@ -24,11 +24,7 @@ func (r *mutationResolver) UserCreate(ctx context.Context, input models.UserCrea err = fac.WithTxn(func() error { u, err = user.Create(fac, input) - if err != nil { - return err - } - - return nil + return err }) if err != nil { diff --git a/pkg/api/resolver_query_find_notifications.go b/pkg/api/resolver_query_find_notifications.go index b483cc7b1..6ad6fa242 100644 --- a/pkg/api/resolver_query_find_notifications.go +++ b/pkg/api/resolver_query_find_notifications.go @@ -18,12 +18,13 @@ func (r *queryNotificationsResolver) Count(ctx context.Context, query *models.Qu fac := r.getRepoFactory(ctx) qb := fac.Notification() currentUser := getCurrentUser(ctx) - return qb.GetNotificationsCount(currentUser.ID) + + return qb.GetNotificationsCount(currentUser.ID, query.Input) } func (r *queryNotificationsResolver) Notifications(ctx context.Context, query *models.QueryNotificationsResult) ([]*models.Notification, error) { fac := r.getRepoFactory(ctx) qb := fac.Notification() currentUser := getCurrentUser(ctx) - return qb.GetNotifications(query.Input, currentUser.ID) + return qb.GetNotifications(currentUser.ID, query.Input) } diff --git a/pkg/api/resolver_query_find_scene.go b/pkg/api/resolver_query_find_scene.go index 7e6e8c1c9..bc3e7869c 100644 --- a/pkg/api/resolver_query_find_scene.go +++ b/pkg/api/resolver_query_find_scene.go @@ -80,6 +80,10 @@ func (r *queryResolver) FindScenesBySceneFingerprints(ctx context.Context, scene return nil, err } + if len(sceneIDs) == 0 { + return make([][]*models.Scene, len(sceneFingerprints)), nil + } + var ids []uuid.UUID for _, id := range sceneIDs { ids = append(ids, id...) diff --git a/pkg/api/resolver_query_notifications.go b/pkg/api/resolver_query_notifications.go index 06fc3c0d1..bc09e8f93 100644 --- a/pkg/api/resolver_query_notifications.go +++ b/pkg/api/resolver_query_notifications.go @@ -2,11 +2,14 @@ package api import ( "context" + + "github.com/stashapp/stash-box/pkg/models" ) func (r *queryResolver) GetUnreadNotificationCount(ctx context.Context) (int, error) { fac := r.getRepoFactory(ctx) qb := fac.Notification() currentUser := getCurrentUser(ctx) - return qb.GetUnreadNotificationsCount(currentUser.ID) + unread := true + return qb.GetNotificationsCount(currentUser.ID, models.QueryNotificationsInput{UnreadOnly: &unread}) } diff --git a/pkg/api/session.go b/pkg/api/session.go index 7d6d56e53..b612e7a47 100644 --- a/pkg/api/session.go +++ b/pkg/api/session.go @@ -14,7 +14,7 @@ const cookieName = "stashbox" const usernameFormKey = "username" const passwordFormKey = "password" const userIDKey = "userID" -const maxCookieAge = 60 * 60 * 1 // 1 hours +const maxCookieAge = 60 * 60 * 24 * 30 // 1 month var sessionStore *sessions.CookieStore diff --git a/pkg/database/database.go b/pkg/database/database.go index 62eaeb0a2..268a37a05 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -4,7 +4,7 @@ import ( "github.com/jmoiron/sqlx" ) -var appSchemaVersion uint = 45 +var appSchemaVersion uint = 46 var databaseProviders map[string]databaseProvider diff --git a/pkg/database/migrations/postgres/46_update_default_notifications.up.sql b/pkg/database/migrations/postgres/46_update_default_notifications.up.sql new file mode 100644 index 000000000..9857918ca --- /dev/null +++ b/pkg/database/migrations/postgres/46_update_default_notifications.up.sql @@ -0,0 +1,7 @@ +DELETE FROM "user_notifications" +WHERE type IN ( + 'FAVORITE_PERFORMER_EDIT', + 'FAVORITE_STUDIO_EDIT', + 'FAVORITE_STUDIO_SCENE', + 'FAVORITE_PERFORMER_SCENE' +); diff --git a/pkg/manager/notifications/notifications.go b/pkg/manager/notifications/notifications.go index 47ae7a0b2..8c93abaf8 100644 --- a/pkg/manager/notifications/notifications.go +++ b/pkg/manager/notifications/notifications.go @@ -8,7 +8,7 @@ import ( func OnApplyEdit(fac models.Repo, edit *models.Edit) { nqb := fac.Notification() eqb := fac.Edit() - if edit.Status == models.VoteStatusEnumAccepted.String() || edit.Status == models.VoteStatusEnumImmediateAccepted.String() { + if edit.Status == models.VoteStatusEnumAccepted.String() || edit.Status == models.VoteStatusEnumImmediateAccepted.String() && edit.Operation == models.OperationEnumCreate.String() { if edit.TargetType == models.TargetTypeEnumScene.String() { sceneID, err := eqb.FindSceneID(edit.ID) if err != nil || sceneID == nil { @@ -48,3 +48,16 @@ func OnEditDownvote(fac models.Repo, edit *models.Edit) { func OnEditComment(fac models.Repo, comment *models.EditComment) { fac.Notification().TriggerEditCommentNotifications(comment.ID) } + +var defaultSubscriptions = []models.NotificationEnum{ + models.NotificationEnumCommentOwnEdit, + models.NotificationEnumDownvoteOwnEdit, + models.NotificationEnumFailedOwnEdit, + models.NotificationEnumCommentCommentedEdit, + models.NotificationEnumCommentVotedEdit, + models.NotificationEnumUpdatedEdit, +} + +func GetDefaultSubscriptions() []models.NotificationEnum { + return defaultSubscriptions +} diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index c1b60dc97..78bc31486 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -215,61 +215,62 @@ type ComplexityRoot struct { } Mutation struct { - ActivateNewUser func(childComplexity int, input ActivateNewUserInput) int - ApplyEdit func(childComplexity int, input ApplyEditInput) int - CancelEdit func(childComplexity int, input CancelEditInput) int - ChangePassword func(childComplexity int, input UserChangePasswordInput) int - ConfirmChangeEmail func(childComplexity int, token uuid.UUID) int - DestroyDraft func(childComplexity int, id uuid.UUID) int - EditComment func(childComplexity int, input EditCommentInput) int - EditVote func(childComplexity int, input EditVoteInput) int - FavoritePerformer func(childComplexity int, id uuid.UUID, favorite bool) int - FavoriteStudio func(childComplexity int, id uuid.UUID, favorite bool) int - GenerateInviteCode func(childComplexity int) int - GenerateInviteCodes func(childComplexity int, input *GenerateInviteCodeInput) int - GrantInvite func(childComplexity int, input GrantInviteInput) int - ImageCreate func(childComplexity int, input ImageCreateInput) int - ImageDestroy func(childComplexity int, input ImageDestroyInput) int - MarkNotificationsRead func(childComplexity int) int - NewUser func(childComplexity int, input NewUserInput) int - PerformerCreate func(childComplexity int, input PerformerCreateInput) int - PerformerDestroy func(childComplexity int, input PerformerDestroyInput) int - PerformerEdit func(childComplexity int, input PerformerEditInput) int - PerformerEditUpdate func(childComplexity int, id uuid.UUID, input PerformerEditInput) int - PerformerUpdate func(childComplexity int, input PerformerUpdateInput) int - RegenerateAPIKey func(childComplexity int, userID *uuid.UUID) int - RequestChangeEmail func(childComplexity int) int - RescindInviteCode func(childComplexity int, code uuid.UUID) int - ResetPassword func(childComplexity int, input ResetPasswordInput) int - RevokeInvite func(childComplexity int, input RevokeInviteInput) int - SceneCreate func(childComplexity int, input SceneCreateInput) int - SceneDestroy func(childComplexity int, input SceneDestroyInput) int - SceneEdit func(childComplexity int, input SceneEditInput) int - SceneEditUpdate func(childComplexity int, id uuid.UUID, input SceneEditInput) int - SceneUpdate func(childComplexity int, input SceneUpdateInput) int - SiteCreate func(childComplexity int, input SiteCreateInput) int - SiteDestroy func(childComplexity int, input SiteDestroyInput) int - SiteUpdate func(childComplexity int, input SiteUpdateInput) int - StudioCreate func(childComplexity int, input StudioCreateInput) int - StudioDestroy func(childComplexity int, input StudioDestroyInput) int - StudioEdit func(childComplexity int, input StudioEditInput) int - StudioEditUpdate func(childComplexity int, id uuid.UUID, input StudioEditInput) int - StudioUpdate func(childComplexity int, input StudioUpdateInput) int - SubmitFingerprint func(childComplexity int, input FingerprintSubmission) int - SubmitPerformerDraft func(childComplexity int, input PerformerDraftInput) int - SubmitSceneDraft func(childComplexity int, input SceneDraftInput) int - TagCategoryCreate func(childComplexity int, input TagCategoryCreateInput) int - TagCategoryDestroy func(childComplexity int, input TagCategoryDestroyInput) int - TagCategoryUpdate func(childComplexity int, input TagCategoryUpdateInput) int - TagCreate func(childComplexity int, input TagCreateInput) int - TagDestroy func(childComplexity int, input TagDestroyInput) int - TagEdit func(childComplexity int, input TagEditInput) int - TagEditUpdate func(childComplexity int, id uuid.UUID, input TagEditInput) int - TagUpdate func(childComplexity int, input TagUpdateInput) int - UserCreate func(childComplexity int, input UserCreateInput) int - UserDestroy func(childComplexity int, input UserDestroyInput) int - UserUpdate func(childComplexity int, input UserUpdateInput) int - ValidateChangeEmail func(childComplexity int, token uuid.UUID, email string) int + ActivateNewUser func(childComplexity int, input ActivateNewUserInput) int + ApplyEdit func(childComplexity int, input ApplyEditInput) int + CancelEdit func(childComplexity int, input CancelEditInput) int + ChangePassword func(childComplexity int, input UserChangePasswordInput) int + ConfirmChangeEmail func(childComplexity int, token uuid.UUID) int + DestroyDraft func(childComplexity int, id uuid.UUID) int + EditComment func(childComplexity int, input EditCommentInput) int + EditVote func(childComplexity int, input EditVoteInput) int + FavoritePerformer func(childComplexity int, id uuid.UUID, favorite bool) int + FavoriteStudio func(childComplexity int, id uuid.UUID, favorite bool) int + GenerateInviteCode func(childComplexity int) int + GenerateInviteCodes func(childComplexity int, input *GenerateInviteCodeInput) int + GrantInvite func(childComplexity int, input GrantInviteInput) int + ImageCreate func(childComplexity int, input ImageCreateInput) int + ImageDestroy func(childComplexity int, input ImageDestroyInput) int + MarkNotificationsRead func(childComplexity int) int + NewUser func(childComplexity int, input NewUserInput) int + PerformerCreate func(childComplexity int, input PerformerCreateInput) int + PerformerDestroy func(childComplexity int, input PerformerDestroyInput) int + PerformerEdit func(childComplexity int, input PerformerEditInput) int + PerformerEditUpdate func(childComplexity int, id uuid.UUID, input PerformerEditInput) int + PerformerUpdate func(childComplexity int, input PerformerUpdateInput) int + RegenerateAPIKey func(childComplexity int, userID *uuid.UUID) int + RequestChangeEmail func(childComplexity int) int + RescindInviteCode func(childComplexity int, code uuid.UUID) int + ResetPassword func(childComplexity int, input ResetPasswordInput) int + RevokeInvite func(childComplexity int, input RevokeInviteInput) int + SceneCreate func(childComplexity int, input SceneCreateInput) int + SceneDestroy func(childComplexity int, input SceneDestroyInput) int + SceneEdit func(childComplexity int, input SceneEditInput) int + SceneEditUpdate func(childComplexity int, id uuid.UUID, input SceneEditInput) int + SceneUpdate func(childComplexity int, input SceneUpdateInput) int + SiteCreate func(childComplexity int, input SiteCreateInput) int + SiteDestroy func(childComplexity int, input SiteDestroyInput) int + SiteUpdate func(childComplexity int, input SiteUpdateInput) int + StudioCreate func(childComplexity int, input StudioCreateInput) int + StudioDestroy func(childComplexity int, input StudioDestroyInput) int + StudioEdit func(childComplexity int, input StudioEditInput) int + StudioEditUpdate func(childComplexity int, id uuid.UUID, input StudioEditInput) int + StudioUpdate func(childComplexity int, input StudioUpdateInput) int + SubmitFingerprint func(childComplexity int, input FingerprintSubmission) int + SubmitPerformerDraft func(childComplexity int, input PerformerDraftInput) int + SubmitSceneDraft func(childComplexity int, input SceneDraftInput) int + TagCategoryCreate func(childComplexity int, input TagCategoryCreateInput) int + TagCategoryDestroy func(childComplexity int, input TagCategoryDestroyInput) int + TagCategoryUpdate func(childComplexity int, input TagCategoryUpdateInput) int + TagCreate func(childComplexity int, input TagCreateInput) int + TagDestroy func(childComplexity int, input TagDestroyInput) int + TagEdit func(childComplexity int, input TagEditInput) int + TagEditUpdate func(childComplexity int, id uuid.UUID, input TagEditInput) int + TagUpdate func(childComplexity int, input TagUpdateInput) int + UpdateNotificationSubscriptions func(childComplexity int, subscriptions []NotificationEnum) int + UserCreate func(childComplexity int, input UserCreateInput) int + UserDestroy func(childComplexity int, input UserDestroyInput) int + UserUpdate func(childComplexity int, input UserUpdateInput) int + ValidateChangeEmail func(childComplexity int, token uuid.UUID, email string) int } Notification struct { @@ -640,18 +641,19 @@ type ComplexityRoot struct { } User struct { - APICalls func(childComplexity int) int - APIKey func(childComplexity int) int - ActiveInviteCodes func(childComplexity int) int - EditCount func(childComplexity int) int - Email func(childComplexity int) int - ID func(childComplexity int) int - InviteCodes func(childComplexity int) int - InviteTokens func(childComplexity int) int - InvitedBy func(childComplexity int) int - Name func(childComplexity int) int - Roles func(childComplexity int) int - VoteCount func(childComplexity int) int + APICalls func(childComplexity int) int + APIKey func(childComplexity int) int + ActiveInviteCodes func(childComplexity int) int + EditCount func(childComplexity int) int + Email func(childComplexity int) int + ID func(childComplexity int) int + InviteCodes func(childComplexity int) int + InviteTokens func(childComplexity int) int + InvitedBy func(childComplexity int) int + Name func(childComplexity int) int + NotificationSubscriptions func(childComplexity int) int + Roles func(childComplexity int) int + VoteCount func(childComplexity int) int } UserEditCount struct { @@ -777,6 +779,7 @@ type MutationResolver interface { FavoritePerformer(ctx context.Context, id uuid.UUID, favorite bool) (bool, error) FavoriteStudio(ctx context.Context, id uuid.UUID, favorite bool) (bool, error) MarkNotificationsRead(ctx context.Context) (bool, error) + UpdateNotificationSubscriptions(ctx context.Context, subscriptions []NotificationEnum) (bool, error) } type NotificationResolver interface { Created(ctx context.Context, obj *Notification) (*time.Time, error) @@ -997,6 +1000,7 @@ type URLResolver interface { type UserResolver interface { Roles(ctx context.Context, obj *User) ([]RoleEnum, error) + NotificationSubscriptions(ctx context.Context, obj *User) ([]NotificationEnum, error) VoteCount(ctx context.Context, obj *User) (*UserVoteCount, error) EditCount(ctx context.Context, obj *User) (*UserEditCount, error) @@ -2133,6 +2137,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.TagUpdate(childComplexity, args["input"].(TagUpdateInput)), true + case "Mutation.updateNotificationSubscriptions": + if e.complexity.Mutation.UpdateNotificationSubscriptions == nil { + break + } + + args, err := ec.field_Mutation_updateNotificationSubscriptions_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateNotificationSubscriptions(childComplexity, args["subscriptions"].([]NotificationEnum)), true + case "Mutation.userCreate": if e.complexity.Mutation.UserCreate == nil { break @@ -4319,6 +4335,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.Name(childComplexity), true + case "User.notification_subscriptions": + if e.complexity.User.NotificationSubscriptions == nil { + break + } + + return e.complexity.User.NotificationSubscriptions(childComplexity), true + case "User.roles": if e.complexity.User.Roles == nil { break @@ -4930,6 +4953,19 @@ input URLInput { data: NotificationData! } +enum NotificationEnum { + FAVORITE_PERFORMER_SCENE + FAVORITE_PERFORMER_EDIT + FAVORITE_STUDIO_SCENE + FAVORITE_STUDIO_EDIT + COMMENT_OWN_EDIT + DOWNVOTE_OWN_EDIT + FAILED_OWN_EDIT + COMMENT_COMMENTED_EDIT + COMMENT_VOTED_EDIT + UPDATED_EDIT +} + union NotificationData = | FavoritePerformerScene | FavoritePerformerEdit @@ -4985,6 +5021,8 @@ type UpdatedEdit { input QueryNotificationsInput { page: Int! = 1 per_page: Int! = 25 + type: NotificationEnum + unread_only: Boolean } type QueryNotificationsResult { @@ -6004,6 +6042,7 @@ type User { email: String @isUserOwner """Should not be visible to other users""" api_key: String @isUserOwner + notification_subscriptions: [NotificationEnum!]! @isUserOwner """ Vote counts by type """ vote_count: UserVoteCount! @@ -6349,6 +6388,8 @@ type Mutation { """Mark all of the current users notifications as read.""" markNotificationsRead: Boolean! @hasRole(role: READ) + """Update notification subscriptions for current user.""" + updateNotificationSubscriptions(subscriptions: [NotificationEnum!]!): Boolean! @hasRole(role: EDIT) } schema { @@ -8093,6 +8134,38 @@ func (ec *executionContext) field_Mutation_tagUpdate_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_updateNotificationSubscriptions_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_updateNotificationSubscriptions_argsSubscriptions(ctx, rawArgs) + if err != nil { + return nil, err + } + args["subscriptions"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_updateNotificationSubscriptions_argsSubscriptions( + ctx context.Context, + rawArgs map[string]interface{}, +) ([]NotificationEnum, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["subscriptions"] + if !ok { + var zeroVal []NotificationEnum + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("subscriptions")) + if tmp, ok := rawArgs["subscriptions"]; ok { + return ec.unmarshalNNotificationEnum2ᚕgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnumᚄ(ctx, tmp) + } + + var zeroVal []NotificationEnum + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_userCreate_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -10368,6 +10441,8 @@ func (ec *executionContext) fieldContext_Edit_user(_ context.Context, field grap return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -11364,6 +11439,8 @@ func (ec *executionContext) fieldContext_EditComment_user(_ context.Context, fie return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -11609,6 +11686,8 @@ func (ec *executionContext) fieldContext_EditVote_user(_ context.Context, field return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -14459,6 +14538,8 @@ func (ec *executionContext) fieldContext_Mutation_userCreate(ctx context.Context return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -14564,6 +14645,8 @@ func (ec *executionContext) fieldContext_Mutation_userUpdate(ctx context.Context return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -14947,6 +15030,8 @@ func (ec *executionContext) fieldContext_Mutation_activateNewUser(ctx context.Co return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -18287,6 +18372,88 @@ func (ec *executionContext) fieldContext_Mutation_markNotificationsRead(_ contex return fc, nil } +func (ec *executionContext) _Mutation_updateNotificationSubscriptions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_updateNotificationSubscriptions(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().UpdateNotificationSubscriptions(rctx, fc.Args["subscriptions"].([]NotificationEnum)) + } + + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRoleEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐRoleEnum(ctx, "EDIT") + if err != nil { + var zeroVal bool + return zeroVal, err + } + if ec.directives.HasRole == nil { + var zeroVal bool + return zeroVal, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(bool); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be bool`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_updateNotificationSubscriptions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateNotificationSubscriptions_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Notification_created(ctx context.Context, field graphql.CollectedField, obj *Notification) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Notification_created(ctx, field) if err != nil { @@ -24784,6 +24951,8 @@ func (ec *executionContext) fieldContext_Query_findUser(ctx context.Context, fie return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -24950,6 +25119,8 @@ func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graph return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -27663,6 +27834,8 @@ func (ec *executionContext) fieldContext_QueryUsersResultType_users(_ context.Co return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -33743,6 +33916,72 @@ func (ec *executionContext) fieldContext_User_api_key(_ context.Context, field g return fc, nil } +func (ec *executionContext) _User_notification_subscriptions(ctx context.Context, field graphql.CollectedField, obj *User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_notification_subscriptions(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.User().NotificationSubscriptions(rctx, obj) + } + + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsUserOwner == nil { + var zeroVal []NotificationEnum + return zeroVal, errors.New("directive isUserOwner is not implemented") + } + return ec.directives.IsUserOwner(ctx, obj, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.([]NotificationEnum); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be []github.com/stashapp/stash-box/pkg/models.NotificationEnum`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]NotificationEnum) + fc.Result = res + return ec.marshalNNotificationEnum2ᚕgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnumᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_notification_subscriptions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type NotificationEnum does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _User_vote_count(ctx context.Context, field graphql.CollectedField, obj *User) (ret graphql.Marshaler) { fc, err := ec.fieldContext_User_vote_count(ctx, field) if err != nil { @@ -33993,6 +34232,8 @@ func (ec *executionContext) fieldContext_User_invited_by(_ context.Context, fiel return ec.fieldContext_User_email(ctx, field) case "api_key": return ec.fieldContext_User_api_key(ctx, field) + case "notification_subscriptions": + return ec.fieldContext_User_notification_subscriptions(ctx, field) case "vote_count": return ec.fieldContext_User_vote_count(ctx, field) case "edit_count": @@ -39065,7 +39306,7 @@ func (ec *executionContext) unmarshalInputQueryNotificationsInput(ctx context.Co asMap["per_page"] = 25 } - fieldsInOrder := [...]string{"page", "per_page"} + fieldsInOrder := [...]string{"page", "per_page", "type", "unread_only"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -39086,6 +39327,20 @@ func (ec *executionContext) unmarshalInputQueryNotificationsInput(ctx context.Co return it, err } it.PerPage = data + case "type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("type")) + data, err := ec.unmarshalONotificationEnum2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "unread_only": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("unread_only")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.UnreadOnly = data } } @@ -43682,6 +43937,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "updateNotificationSubscriptions": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateNotificationSubscriptions(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -50360,6 +50622,42 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._User_email(ctx, field, obj) case "api_key": out.Values[i] = ec._User_api_key(ctx, field, obj) + case "notification_subscriptions": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._User_notification_subscriptions(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "vote_count": field := field @@ -51987,6 +52285,77 @@ func (ec *executionContext) marshalNNotificationData2githubᚗcomᚋstashappᚋs return ec._NotificationData(ctx, sel, v) } +func (ec *executionContext) unmarshalNNotificationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx context.Context, v interface{}) (NotificationEnum, error) { + var res NotificationEnum + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNNotificationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx context.Context, sel ast.SelectionSet, v NotificationEnum) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNNotificationEnum2ᚕgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnumᚄ(ctx context.Context, v interface{}) ([]NotificationEnum, error) { + var vSlice []interface{} + if v != nil { + vSlice = graphql.CoerceList(v) + } + var err error + res := make([]NotificationEnum, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNNotificationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNNotificationEnum2ᚕgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnumᚄ(ctx context.Context, sel ast.SelectionSet, v []NotificationEnum) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNNotificationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalNOperationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐOperationEnum(ctx context.Context, v interface{}) (OperationEnum, error) { var res OperationEnum err := res.UnmarshalGQL(v) @@ -54299,6 +54668,22 @@ func (ec *executionContext) unmarshalOMultiStringCriterionInput2ᚖgithubᚗcom return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalONotificationEnum2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx context.Context, v interface{}) (*NotificationEnum, error) { + if v == nil { + return nil, nil + } + var res = new(NotificationEnum) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalONotificationEnum2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx context.Context, sel ast.SelectionSet, v *NotificationEnum) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOOperationEnum2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐOperationEnum(ctx context.Context, v interface{}) (*OperationEnum, error) { if v == nil { return nil, nil diff --git a/pkg/models/generated_models.go b/pkg/models/generated_models.go index 3d87525d9..c49ef9ccc 100644 --- a/pkg/models/generated_models.go +++ b/pkg/models/generated_models.go @@ -516,8 +516,10 @@ type QueryExistingSceneInput struct { } type QueryNotificationsInput struct { - Page int `json:"page"` - PerPage int `json:"per_page"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Type *NotificationEnum `json:"type,omitempty"` + UnreadOnly *bool `json:"unread_only,omitempty"` } type QuerySitesResultType struct { @@ -1552,6 +1554,63 @@ func (e HairColorEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type NotificationEnum string + +const ( + NotificationEnumFavoritePerformerScene NotificationEnum = "FAVORITE_PERFORMER_SCENE" + NotificationEnumFavoritePerformerEdit NotificationEnum = "FAVORITE_PERFORMER_EDIT" + NotificationEnumFavoriteStudioScene NotificationEnum = "FAVORITE_STUDIO_SCENE" + NotificationEnumFavoriteStudioEdit NotificationEnum = "FAVORITE_STUDIO_EDIT" + NotificationEnumCommentOwnEdit NotificationEnum = "COMMENT_OWN_EDIT" + NotificationEnumDownvoteOwnEdit NotificationEnum = "DOWNVOTE_OWN_EDIT" + NotificationEnumFailedOwnEdit NotificationEnum = "FAILED_OWN_EDIT" + NotificationEnumCommentCommentedEdit NotificationEnum = "COMMENT_COMMENTED_EDIT" + NotificationEnumCommentVotedEdit NotificationEnum = "COMMENT_VOTED_EDIT" + NotificationEnumUpdatedEdit NotificationEnum = "UPDATED_EDIT" +) + +var AllNotificationEnum = []NotificationEnum{ + NotificationEnumFavoritePerformerScene, + NotificationEnumFavoritePerformerEdit, + NotificationEnumFavoriteStudioScene, + NotificationEnumFavoriteStudioEdit, + NotificationEnumCommentOwnEdit, + NotificationEnumDownvoteOwnEdit, + NotificationEnumFailedOwnEdit, + NotificationEnumCommentCommentedEdit, + NotificationEnumCommentVotedEdit, + NotificationEnumUpdatedEdit, +} + +func (e NotificationEnum) IsValid() bool { + switch e { + case NotificationEnumFavoritePerformerScene, NotificationEnumFavoritePerformerEdit, NotificationEnumFavoriteStudioScene, NotificationEnumFavoriteStudioEdit, NotificationEnumCommentOwnEdit, NotificationEnumDownvoteOwnEdit, NotificationEnumFailedOwnEdit, NotificationEnumCommentCommentedEdit, NotificationEnumCommentVotedEdit, NotificationEnumUpdatedEdit: + return true + } + return false +} + +func (e NotificationEnum) String() string { + return string(e) +} + +func (e *NotificationEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = NotificationEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid NotificationEnum", str) + } + return nil +} + +func (e NotificationEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type OperationEnum string const ( diff --git a/pkg/models/joins.go b/pkg/models/joins.go index e6784246f..97c1472a5 100644 --- a/pkg/models/joins.go +++ b/pkg/models/joins.go @@ -24,4 +24,6 @@ type JoinsRepo interface { DestroyPerformerFavorite(join PerformerFavorite) error IsPerformerFavorite(favorite PerformerFavorite) (bool, error) IsStudioFavorite(favorite StudioFavorite) (bool, error) + UpdateUserNotifications(userID uuid.UUID, updatedJoins UserNotifications) error + GetUserNotifications(userID uuid.UUID) ([]NotificationEnum, error) } diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index 1cd5302b8..52fea17b3 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -226,3 +226,33 @@ type StudioFavorite struct { StudioID uuid.UUID `db:"studio_id" json:"studio_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` } + +type UserNotification struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Type NotificationEnum `db:"type" json:"type"` +} + +type UserNotifications []*UserNotification + +func (u *UserNotifications) Add(o interface{}) { + *u = append(*u, o.(*UserNotification)) +} + +func (u UserNotifications) Each(fn func(interface{})) { + for _, v := range u { + fn(*v) + } +} + +func CreateUserNotifications(userID uuid.UUID, subscriptions []NotificationEnum) UserNotifications { + var ret UserNotifications + + for _, sub := range subscriptions { + ret = append(ret, &UserNotification{ + UserID: userID, + Type: sub, + }) + } + + return ret +} diff --git a/pkg/models/model_notification.go b/pkg/models/model_notification.go index c9e0f82a5..944816935 100644 --- a/pkg/models/model_notification.go +++ b/pkg/models/model_notification.go @@ -7,21 +7,6 @@ import ( "github.com/gofrs/uuid" ) -type NotificationEnum string - -const ( - NotificationEnumFavoritePerformerScene NotificationEnum = "FAVORITE_PERFORMER_SCENE" - NotificationEnumFavoritePerformerEdit NotificationEnum = "FAVORITE_PERFORMER_EDIT" - NotificationEnumFavoriteStudioScene NotificationEnum = "FAVORITE_STUDIO_SCENE" - NotificationEnumFavoriteStudioEdit NotificationEnum = "FAVORITE_STUDIO_EDIT" - NotificationEnumCommentOwnEdit NotificationEnum = "COMMENT_OWN_EDIT" - NotificationEnumDownvoteOwnEdit NotificationEnum = "DOWNVOTE_OWN_EDIT" - NotificationEnumFailedOwnEdit NotificationEnum = "FAILED_OWN_EDIT" - NotificationEnumCommentCommentedEdit NotificationEnum = "COMMENT_COMMENTED_EDIT" - NotificationEnumCommentVotedEdit NotificationEnum = "COMMENT_VOTED_EDIT" - NotificationEnumUpdatedEdit NotificationEnum = "UPDATED_EDIT" -) - type Notification struct { UserID uuid.UUID `db:"user_id" json:"user_id"` Type NotificationEnum `db:"type" json:"type"` diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 536ddea31..41fd8f5cf 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -3,9 +3,9 @@ package models import "github.com/gofrs/uuid" type NotificationRepo interface { - GetNotificationsCount(userID uuid.UUID) (int, error) - GetUnreadNotificationsCount(userID uuid.UUID) (int, error) - GetNotifications(filter QueryNotificationsInput, userID uuid.UUID) ([]*Notification, error) + GetNotifications(userID uuid.UUID, filter QueryNotificationsInput) ([]*Notification, error) + GetNotificationsCount(userID uuid.UUID, filter QueryNotificationsInput) (int, error) + MarkRead(userID uuid.UUID) error TriggerSceneCreationNotifications(sceneID uuid.UUID) error TriggerPerformerEditNotifications(editID uuid.UUID) error diff --git a/pkg/sqlx/querybuilder_invite_key.go b/pkg/sqlx/querybuilder_invite_key.go index c79159503..58ceaa4de 100644 --- a/pkg/sqlx/querybuilder_invite_key.go +++ b/pkg/sqlx/querybuilder_invite_key.go @@ -37,12 +37,16 @@ func (p *inviteKeyRow) fromInviteKey(i models.InviteKey) { } func (p inviteKeyRow) resolve() models.InviteKey { + var expires *time.Time + if p.ExpireTime.Valid { + expires = &p.ExpireTime.Time + } return models.InviteKey{ ID: p.ID, Uses: intPtrFromNullInt(p.Uses), GeneratedBy: p.GeneratedBy, GeneratedAt: p.GeneratedAt, - Expires: &p.ExpireTime.Time, + Expires: expires, } } diff --git a/pkg/sqlx/querybuilder_joins.go b/pkg/sqlx/querybuilder_joins.go index e9166c16c..6acb0622d 100644 --- a/pkg/sqlx/querybuilder_joins.go +++ b/pkg/sqlx/querybuilder_joins.go @@ -41,6 +41,10 @@ var ( performerFavoriteTable = newTableJoin(performerTable, "performer_favorites", performerJoinKey, func() interface{} { return &models.PerformerFavorite{} }) + + userNotificationTable = newTableJoin(userTable, "user_notifications", userJoinKey, func() interface{} { + return &models.UserNotification{} + }) ) type joinsQueryBuilder struct { @@ -156,3 +160,22 @@ func (qb *joinsQueryBuilder) IsStudioFavorite(favorite models.StudioFavorite) (b res, err := runCountQuery(qb.dbi.db(), query, args) return res > 0, err } + +func (qb *joinsQueryBuilder) UpdateUserNotifications(userID uuid.UUID, updatedJoins models.UserNotifications) error { + return qb.dbi.ReplaceJoins(userNotificationTable, userID, &updatedJoins) +} + +func (qb *joinsQueryBuilder) GetUserNotifications(userID uuid.UUID) ([]models.NotificationEnum, error) { + var joins models.UserNotifications + if err := qb.dbi.FindJoins(userNotificationTable, userID, &joins); err != nil { + return nil, err + } + + var notifications []models.NotificationEnum + var notificationJoins []*models.UserNotification = joins + for _, notification := range notificationJoins { + notifications = append(notifications, notification.Type) + } + + return notifications, nil +} diff --git a/pkg/sqlx/querybuilder_notifications.go b/pkg/sqlx/querybuilder_notifications.go index 38b246d09..bbff2a295 100644 --- a/pkg/sqlx/querybuilder_notifications.go +++ b/pkg/sqlx/querybuilder_notifications.go @@ -164,21 +164,13 @@ INSERT INTO notifications return err } -func (qb *notificationsQueryBuilder) GetUnreadNotificationsCount(userID uuid.UUID) (int, error) { - var args []interface{} - args = append(args, userID) - return runCountQuery(qb.dbi.db(), buildCountQuery("SELECT * FROM notifications WHERE user_id = ? AND read_at IS NULL"), args) -} - -func (qb *notificationsQueryBuilder) GetNotificationsCount(userID uuid.UUID) (int, error) { - var args []interface{} - args = append(args, userID) - return runCountQuery(qb.dbi.db(), buildCountQuery("SELECT * FROM notifications WHERE user_id = ?"), args) +func (qb *notificationsQueryBuilder) GetNotificationsCount(userID uuid.UUID, filter models.QueryNotificationsInput) (int, error) { + query := buildQuery(userID, filter) + return qb.dbi.CountOnly(*query) } -func (qb *notificationsQueryBuilder) GetNotifications(filter models.QueryNotificationsInput, userID uuid.UUID) ([]*models.Notification, error) { - query := newQueryBuilder(notificationDBTable) - query.Eq("user_id", userID) +func (qb *notificationsQueryBuilder) GetNotifications(userID uuid.UUID, filter models.QueryNotificationsInput) ([]*models.Notification, error) { + query := buildQuery(userID, filter) query.Pagination = getPagination(filter.Page, filter.PerPage) query.Sort = getSort("created_at", models.SortDirectionEnumDesc.String(), notificationDBTable.name, nil) @@ -191,3 +183,26 @@ func (qb *notificationsQueryBuilder) GetNotifications(filter models.QueryNotific return notifications, nil } + +func (qb *notificationsQueryBuilder) MarkRead(userID uuid.UUID) error { + args := []interface{}{userID} + return qb.dbi.RawExec("UPDATE notifications SET read_at = now() WHERE user_id = ? AND read_at IS NULL", args) +} + +func buildQuery(userID uuid.UUID, filter models.QueryNotificationsInput) *queryBuilder { + query := newQueryBuilder(notificationDBTable) + + query.AddWhere("user_id = ?") + query.AddArg(userID) + + if filter.UnreadOnly != nil && *filter.UnreadOnly { + query.AddWhere("read_at IS NULL") + } + + if filter.Type != nil { + query.AddWhere("type = ?") + query.AddArg(filter.Type.String()) + } + + return query +} diff --git a/pkg/sqlx/querybuilder_scene.go b/pkg/sqlx/querybuilder_scene.go index 187693145..8f65ba688 100644 --- a/pkg/sqlx/querybuilder_scene.go +++ b/pkg/sqlx/querybuilder_scene.go @@ -253,7 +253,7 @@ func (qb *sceneQueryBuilder) FindByIds(ids []uuid.UUID) ([]*models.Scene, []erro for i, id := range ids { result[i] = m[id] } - return result, nil + return result, utils.DuplicateError(nil, len(ids)) } func (qb *sceneQueryBuilder) FindIdsBySceneFingerprints(fingerprints []*models.FingerprintQueryInput) (map[string][]uuid.UUID, error) { diff --git a/pkg/user/user.go b/pkg/user/user.go index a0097f8ad..d73a6f2f2 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -10,6 +10,7 @@ import ( "github.com/gofrs/uuid" "github.com/stashapp/stash-box/pkg/manager/config" + "github.com/stashapp/stash-box/pkg/manager/notifications" "github.com/stashapp/stash-box/pkg/models" "github.com/stashapp/stash-box/pkg/utils" ) @@ -263,6 +264,12 @@ func Create(fac models.Repo, input models.UserCreateInput) (*models.User, error) return nil, err } + // Save the notification subscriptions + notificationSubscriptions := models.CreateUserNotifications(user.ID, notifications.GetDefaultSubscriptions()) + if err := fac.Joins().UpdateUserNotifications(user.ID, notificationSubscriptions); err != nil { + return nil, err + } + return user, nil }