diff --git a/client/package-lock.json b/client/package-lock.json index 721fda7..72a4a13 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1125,9 +1125,9 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" }, "@fortawesome/fontawesome-free": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz", - "integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.0.tgz", + "integrity": "sha512-wXetjQBNMTP59MAYNR1tdahMDOLx3FYj3PKdso7PLFLDpTvmAIqhSSEqnSTmWKahRjD+Sh5I5635+5qaoib5lw==", "dev": true }, "@nodelib/fs.scandir": { @@ -3197,6 +3197,11 @@ "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz", "integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ==" }, + "bulma-timeline": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/bulma-timeline/-/bulma-timeline-3.0.4.tgz", + "integrity": "sha512-gCUOcSUuzHoeVMkCpLF49j5Z5yl78XQ+KgJcT+1ju5WIGgBgVytRUob/dw5NHAxPLO2rmcvwYNbCJFp7w4WT4Q==" + }, "bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -4484,9 +4489,9 @@ "dev": true }, "elliptic": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", - "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", + "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -6551,9 +6556,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "loglevel": { "version": "1.6.8", @@ -7052,9 +7057,9 @@ } }, "node-forge": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz", - "integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", "dev": true }, "node-gyp": { @@ -8745,12 +8750,12 @@ "dev": true }, "selfsigned": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz", - "integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==", + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz", + "integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==", "dev": true, "requires": { - "node-forge": "0.9.0" + "node-forge": "^0.10.0" } }, "semver": { diff --git a/client/package.json b/client/package.json index 9a35688..b83fa25 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,7 @@ "@babel/plugin-transform-runtime": "^7.7.4", "@babel/preset-env": "^7.7.1", "@babel/preset-react": "^7.7.4", - "@fortawesome/fontawesome-free": "^5.11.2", + "@fortawesome/fontawesome-free": "^5.14.0", "@types/node": "^13.13.4", "@types/react-dom": "^16.9.7", "@types/react-redux": "^7.1.7", @@ -55,6 +55,7 @@ "@types/qs": "^6.9.3", "bs58": "^4.0.1", "bulma": "^0.9.0", + "bulma-timeline": "^3.0.4", "graphql": "^15.3.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index adfb1ae..9373b32 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -74,7 +74,7 @@ const UserSessionCheck: FunctionComponent = ({ setLoggedI useEffect(() => { if (loading) return; - setLoggedIn(user.id !== ''); + setLoggedIn(user && user.id !== ''); }, [user]); return null; diff --git a/client/src/components/DashboardPage/Dashboard.tsx b/client/src/components/DashboardPage/Dashboard.tsx index 967cac2..528e026 100644 --- a/client/src/components/DashboardPage/Dashboard.tsx +++ b/client/src/components/DashboardPage/Dashboard.tsx @@ -1,29 +1,35 @@ import React from 'react'; import { WorkgroupsPanel } from './WorkgroupsPanel'; import { DecisionSupportFilePanel } from './DecisionSupportFilePanel'; +import { Timeline } from '../Timeline'; +import { useEvents } from '../../gql/queries/event'; + +const from = new Date(); +from.setDate(from.getDate() - 7); export function Dashboard() { + const { events } = useEvents({ + variables: { + filter: { + from, + } + } + }); + return (
-
+
+
+

Ces 7 derniers jours

+ +
+
+
-
-
-
-
-

Assemblées

-
-
- -
-
-
TODO
-
-
); } \ No newline at end of file diff --git a/client/src/components/DecisionSupportFileLink.tsx b/client/src/components/DecisionSupportFileLink.tsx new file mode 100644 index 0000000..4e943e9 --- /dev/null +++ b/client/src/components/DecisionSupportFileLink.tsx @@ -0,0 +1,25 @@ +import React, { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useWorkgroups } from "../gql/queries/workgroups"; +import { useDecisionSupportFiles } from "../gql/queries/dsf"; + +export interface DecisioSupportFileLinkProps { + decisionSupportFileId: number +} + +export const DecisioSupportFileLink: FunctionComponent = ({ decisionSupportFileId }) => { + const { decisionSupportFiles } = useDecisionSupportFiles({ + fetchPolicy: "cache-first", + variables: { + filter: { + ids: [decisionSupportFileId] + } + } + }); + + const title = decisionSupportFiles.length > 0 ? decisionSupportFiles[0].title : `#${decisionSupportFileId}`; + + return ( + {title} + ); +}; \ No newline at end of file diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index a323f4e..b240eda 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -1,6 +1,5 @@ import React, { Fragment, useState } from 'react'; import logo from '../resources/logo.svg'; -import { useSelector } from 'react-redux'; import { Config } from '../config'; import { Link } from 'react-router-dom'; import { useLoggedIn } from '../hooks/useLoggedIn'; diff --git a/client/src/components/Timeline.tsx b/client/src/components/Timeline.tsx new file mode 100644 index 0000000..036f352 --- /dev/null +++ b/client/src/components/Timeline.tsx @@ -0,0 +1,208 @@ +import React, { FunctionComponent } from "react"; +import { formatDate } from "../util/date"; +import { Event } from "../types/event"; +import { Link } from "react-router-dom"; +import { WorkgroupLink } from "./WorkgroupLink"; +import { DecisioSupportFileLink } from "./DecisionSupportFileLink"; + +export interface TimelineProps { + events?: Event[] +} + +export const Timeline: FunctionComponent = ({ events }) => { + events = debounceEvents(events) || []; + return ( + +
+ { + events.map(evt => { + return ( +
+ {renderEventMarker(evt)} +
+

{formatDate(evt.createdAt)}

+ {renderEventContent(evt)} +
+
+ ); + }) + } + { + events.length === 0 ? +

Aucun évènement.

: + null + } +
+
+ ); +} + +function debounceEvents(events: Event[]): Event[] { + const debounced = []; + for(let evt: Event, i = 0; (evt = events[i]); ++i) { + const prev = i > 0 ? events[i-1] : null; + + if (!prev) { + debounced.push(evt); + continue; + } + + const isSame = evt.objectId === prev.objectId && + evt.objectType === prev.objectType && + evt.type === prev.type && + evt.user.id === prev.user.id + ; + + if (isSame) continue; + + debounced.push(evt); + } + + return debounced; +} + +const eventMarkerMap = { + "closed": (evt:Event) => ( +
+ +
+ ), + "created": (evt:Event) => ( +
+ +
+ ), + "updated": (evt:Event) => ( +
+ +
+ ), + "title-changed": (evt:Event) => ( +
+ +
+ ), + "status-changed": (evt:Event) => ( +
+ +
+ ), + "joined": (evt:Event) => ( +
+ +
+ ), + "leaved": (evt:Event) => ( +
+ +
+ ), +} + +function renderEventMarker(evt: Event) { + const render = eventMarkerMap[evt.type]; + if (!render) return (
); + return render(evt); +} + +const eventContentMap = { + "created": { + "workgroup": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a créé le groupe de travail `} + "". + + ); + }, + "dsf": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a créé le dossier d'aide à la décision `} + "". + + ); + }, + }, + "title-changed": { + "dsf": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a modifié le titre du dossier d'aide à la décision `} + "". + + ) + } + }, + "status-changed": { + "dsf": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a modifié le statut du dossier d'aide à la décision `} + "". + + ) + } + }, + "joined": { + "workgroup": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a rejoint le groupe de travail `} + "". + + ); + }, + }, + "updated": { + "workgroup": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a mis à jour le groupe de travail `} + "". + + ); + }, + "dsf": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a modifié le dossier d'aide à la décision `} + "". + + ); + }, + }, + "leaved": { + "workgroup": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a quitté le groupe de travail `} + "". + + ); + }, + }, + "closed": { + "workgroup": (evt:Event) => { + return ( + + {`${evt.user.name ? evt.user.name : evt.user.email} a clos le groupe de travail `} + "". + + ); + }, + }, +}; + +function renderEventContent(evt: Event) { + const eventTypeMap = eventContentMap[evt.type]; + const render = eventTypeMap && eventTypeMap[evt.objectType]; + + if (!eventTypeMap || !render) { + return ( + {`Type d'évènement "${evt.type}/${evt.objectType}" inconnu.`} + ); + } + + return render(evt); +} \ No newline at end of file diff --git a/client/src/components/WorkgroupLink.tsx b/client/src/components/WorkgroupLink.tsx new file mode 100644 index 0000000..870ae1c --- /dev/null +++ b/client/src/components/WorkgroupLink.tsx @@ -0,0 +1,24 @@ +import React, { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useWorkgroups } from "../gql/queries/workgroups"; + +export interface WorkgroupLinkProps { + workgroupId: number +} + +export const WorkgroupLink: FunctionComponent = ({ workgroupId }) => { + const { workgroups } = useWorkgroups({ + fetchPolicy: "cache-first", + variables: { + filter: { + ids: [workgroupId] + } + } + }); + + const workgroupName = workgroups.length > 0 ? workgroups[0].name : `#${workgroupId}`; + + return ( + {workgroupName} + ); +}; \ No newline at end of file diff --git a/client/src/components/WorkgroupPage/InfoForm.tsx b/client/src/components/WorkgroupPage/InfoForm.tsx index 9921b36..224106b 100644 --- a/client/src/components/WorkgroupPage/InfoForm.tsx +++ b/client/src/components/WorkgroupPage/InfoForm.tsx @@ -1,6 +1,7 @@ import React, { useState, ChangeEvent, useEffect } from 'react'; import { Workgroup } from '../../types/workgroup'; import { useIsAuthorized } from '../../gql/queries/authorization'; +import { formatDate } from '../../util/date'; export interface InfoFormProps { workgroup: Workgroup @@ -80,7 +81,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
-

{state.workgroup.createdAt}

+

{formatDate(state.workgroup.createdAt)}

: null @@ -90,7 +91,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
-

{state.workgroup.closedAt}

+

{formatDate(state.workgroup.closedAt)}

: null diff --git a/client/src/gql/queries/event.ts b/client/src/gql/queries/event.ts new file mode 100644 index 0000000..d6f927c --- /dev/null +++ b/client/src/gql/queries/event.ts @@ -0,0 +1,31 @@ +import { gql, useQuery, QueryHookOptions } from '@apollo/client'; +import { useGraphQLData } from './helper'; +import { Event } from '../../types/event'; + +export const QUERY_EVENTS = gql` + query events($filter: EventFilter) { + events(filter: $filter) { + id + user { + id + name + email + } + type + objectType + objectId + createdAt + } + } +`; + +export function useEventsQuery>(options: QueryHookOptions = {}) { + return useQuery(QUERY_EVENTS, options); +} + +export function useEvents>(options: QueryHookOptions = {}) { + const { data, loading, error } = useGraphQLData( + QUERY_EVENTS, 'events', [], options + ); + return { events: data, loading, error }; +} \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index 03c2010..11d88f4 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -8,7 +8,6 @@ import '@fortawesome/fontawesome-free/js/solid' import '@fortawesome/fontawesome-free/js/regular' import '@fortawesome/fontawesome-free/js/brands' import './resources/favicon.png'; -import { ApolloProvider } from '@apollo/client'; ReactDOM.render( , diff --git a/client/src/sass/_all.scss b/client/src/sass/_all.scss index c08fa8c..19ee6a0 100644 --- a/client/src/sass/_all.scss +++ b/client/src/sass/_all.scss @@ -1,3 +1,5 @@ @import 'bulma/bulma.sass'; +@import 'bulma-timeline/dist/css/bulma-timeline.sass'; +@import '_bulma-timeline.scss'; @import '_base.scss'; @import '_loader.scss'; \ No newline at end of file diff --git a/client/src/sass/_bulma-timeline.scss b/client/src/sass/_bulma-timeline.scss new file mode 100644 index 0000000..20b9469 --- /dev/null +++ b/client/src/sass/_bulma-timeline.scss @@ -0,0 +1,12 @@ +.timeline { + .timeline-item { + .timeline-marker { + &.is-icon { + > svg { + color: $white; + font-size: $timeline-icon-size !important; + } + } + } + } +} \ No newline at end of file diff --git a/client/src/types/event.ts b/client/src/types/event.ts new file mode 100644 index 0000000..1f57fa3 --- /dev/null +++ b/client/src/types/event.ts @@ -0,0 +1,11 @@ +import { User } from "./user"; + +export interface Event { + id: string + createdAt: Date + updatedAt: Date + user: User + objectType: string + objectId: number + type: string +} \ No newline at end of file diff --git a/cmd/server/migration.go b/cmd/server/migration.go index af64593..c58ed5e 100644 --- a/cmd/server/migration.go +++ b/cmd/server/migration.go @@ -81,6 +81,7 @@ var initialModels = []interface{}{ &model.User{}, &model.Workgroup{}, &model.DecisionSupportFile{}, + &model.Event{}, } func m000initialSchema() orm.Migration { diff --git a/internal/graph/dsf_handler.go b/internal/graph/dsf_handler.go index b0fdc78..0df7709 100644 --- a/internal/graph/dsf_handler.go +++ b/internal/graph/dsf_handler.go @@ -2,7 +2,7 @@ package graph import ( "context" - "encoding/json" + "reflect" "forge.cadoles.com/Cadoles/daddy/internal/orm" "gitlab.com/wpetit/goweb/middleware/container" @@ -12,6 +12,11 @@ import ( ) func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) { + user, db, err := getSessionUser(ctx) + if err != nil { + return nil, errs.WithStack(err) + } + authorized, err := isAuthorized(ctx, &model.DecisionSupportFile{}, model.ActionCreate) if err != nil { return nil, errs.WithStack(err) @@ -21,9 +26,6 @@ func handleCreateDecisionSupportFile(ctx context.Context, changes *model.Decisio return nil, errs.WithStack(ErrForbidden) } - ctn := container.Must(ctx) - db := orm.Must(ctn).DB() - repo := model.NewDSFRepository(db) dsf, err := repo.Create(ctx, changes) @@ -31,21 +33,29 @@ func handleCreateDecisionSupportFile(ctx context.Context, changes *model.Decisio return nil, errs.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeCreated, dsf); err != nil { + return nil, errs.WithStack(err) + } + return dsf, nil } func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) { - ctn := container.Must(ctx) - db := orm.Must(ctn).DB() - - repo := model.NewDSFRepository(db) - - dsf, err := repo.Find(ctx, id) + user, db, err := getSessionUser(ctx) if err != nil { return nil, errs.WithStack(err) } - authorized, err := isAuthorized(ctx, dsf, model.ActionUpdate) + repo := model.NewDSFRepository(db) + + prevDsf, err := repo.Find(ctx, id) + if err != nil { + return nil, errs.WithStack(err) + } + + authorized, err := isAuthorized(ctx, prevDsf, model.ActionUpdate) if err != nil { return nil, errs.WithStack(err) } @@ -54,11 +64,31 @@ func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *mo return nil, errs.WithStack(ErrForbidden) } - dsf, err = repo.Update(ctx, id, changes) + dsf, err := repo.Update(ctx, id, changes) if err != nil { return nil, errs.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if changes != nil && changes.Status != nil && prevDsf.Status != *changes.Status { + if _, err := eventRepo.Add(ctx, user, model.EventTypeStatusChanged, dsf); err != nil { + return nil, errs.WithStack(err) + } + } + + if changes != nil && changes.Title != nil && prevDsf.Title != *changes.Title { + if _, err := eventRepo.Add(ctx, user, model.EventTypeTitleChanged, dsf); err != nil { + return nil, errs.WithStack(err) + } + } + + if changes != nil && !reflect.DeepEqual(prevDsf.Sections, dsf.Sections) { + if _, err := eventRepo.Add(ctx, user, model.EventTypeUpdated, dsf); err != nil { + return nil, errs.WithStack(err) + } + } + return dsf, nil } @@ -88,13 +118,3 @@ func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSuppo return dsfs, nil } - -func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) { - sections := make(map[string]interface{}) - - if err := json.Unmarshal(dsf.Sections.RawMessage, §ions); err != nil { - return nil, errs.WithStack(err) - } - - return sections, nil -} diff --git a/internal/graph/event_handler.go b/internal/graph/event_handler.go new file mode 100644 index 0000000..a08750e --- /dev/null +++ b/internal/graph/event_handler.go @@ -0,0 +1,24 @@ +package graph + +import ( + "context" + + "forge.cadoles.com/Cadoles/daddy/internal/model" + "forge.cadoles.com/Cadoles/daddy/internal/orm" + errs "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/middleware/container" +) + +func handleEvents(ctx context.Context, filter *model.EventFilter) ([]*model.Event, error) { + ctn := container.Must(ctx) + db := orm.Must(ctn).DB() + + repo := model.NewEventRepository(db) + + events, err := repo.Search(ctx, filter) + if err != nil { + return nil, errs.WithStack(err) + } + + return events, nil +} diff --git a/internal/graph/query.graphql b/internal/graph/query.graphql index a84579b..0599fe8 100644 --- a/internal/graph/query.graphql +++ b/internal/graph/query.graphql @@ -18,6 +18,16 @@ type Workgroup { members: [User]! } +type Event { + id: ID! + type: String! + createdAt: Time! + updatedAt: Time! + objectId: ID! + objectType: String! + user: User! +} + input WorkgroupsFilter { ids: [ID] } @@ -44,9 +54,19 @@ input AuthorizationObject { decisionSupportFileId: ID } +input EventFilter { + objectType: String + objectId: ID + userId: ID + type: String + from: Time + to: Time +} + type Query { userProfile: User workgroups(filter: WorkgroupsFilter): [Workgroup]! decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]! + events(filter: EventFilter): [Event]! isAuthorized(action: String!, object: AuthorizationObject!): Boolean! } diff --git a/internal/graph/query.resolvers.go b/internal/graph/query.resolvers.go index a36d7de..51df2a0 100644 --- a/internal/graph/query.resolvers.go +++ b/internal/graph/query.resolvers.go @@ -15,8 +15,16 @@ func (r *decisionSupportFileResolver) ID(ctx context.Context, obj *model1.Decisi return strconv.FormatUint(uint64(obj.ID), 10), nil } -func (r *decisionSupportFileResolver) Sections(ctx context.Context, obj *model1.DecisionSupportFile) (map[string]interface{}, error) { - return handleSections(ctx, obj) +func (r *eventResolver) ID(ctx context.Context, obj *model1.Event) (string, error) { + return strconv.FormatUint(uint64(obj.ID), 10), nil +} + +func (r *eventResolver) Type(ctx context.Context, obj *model1.Event) (string, error) { + return string(obj.Type), nil +} + +func (r *eventResolver) ObjectID(ctx context.Context, obj *model1.Event) (string, error) { + return strconv.FormatUint(uint64(obj.ObjectID), 10), nil } func (r *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) { @@ -31,6 +39,10 @@ func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1 return handleDecisionSupportFiles(ctx, filter) } +func (r *queryResolver) Events(ctx context.Context, filter *model1.EventFilter) ([]*model1.Event, error) { + return handleEvents(ctx, filter) +} + func (r *queryResolver) IsAuthorized(ctx context.Context, action string, object model1.AuthorizationObject) (bool, error) { return handleIsAuthorized(ctx, action, object) } @@ -48,6 +60,9 @@ func (r *Resolver) DecisionSupportFile() generated.DecisionSupportFileResolver { return &decisionSupportFileResolver{r} } +// Event returns generated.EventResolver implementation. +func (r *Resolver) Event() generated.EventResolver { return &eventResolver{r} } + // Query returns generated.QueryResolver implementation. func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } @@ -58,6 +73,17 @@ func (r *Resolver) User() generated.UserResolver { return &userResolver{r} } func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} } type decisionSupportFileResolver struct{ *Resolver } +type eventResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type userResolver struct{ *Resolver } type workgroupResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +func (r *decisionSupportFileResolver) Sections(ctx context.Context, obj *model1.DecisionSupportFile) (map[string]interface{}, error) { + return obj.Sections, nil +} diff --git a/internal/graph/workgroup_handler.go b/internal/graph/workgroup_handler.go index 6eab3e4..c7becf7 100644 --- a/internal/graph/workgroup_handler.go +++ b/internal/graph/workgroup_handler.go @@ -72,6 +72,12 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor return nil, errors.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeJoined, workgroup); err != nil { + return nil, errors.WithStack(err) + } + return workgroup, nil } @@ -102,6 +108,12 @@ func handleLeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workg return nil, errors.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeLeaved, workgroup); err != nil { + return nil, errors.WithStack(err) + } + return workgroup, nil } @@ -115,7 +127,7 @@ func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) return nil, errs.WithStack(ErrForbidden) } - db, err := getDB(ctx) + user, db, err := getSessionUser(ctx) if err != nil { return nil, errors.WithStack(err) } @@ -127,11 +139,17 @@ func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) return nil, errors.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeCreated, workgroup); err != nil { + return nil, errors.WithStack(err) + } + return workgroup, nil } func handleCloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) { - db, err := getDB(ctx) + user, db, err := getSessionUser(ctx) if err != nil { return nil, errors.WithStack(err) } @@ -157,11 +175,17 @@ func handleCloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workg return nil, errors.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeClosed, workgroup); err != nil { + return nil, errors.WithStack(err) + } + return workgroup, nil } func handleUpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) { - db, err := getDB(ctx) + user, db, err := getSessionUser(ctx) if err != nil { return nil, errors.WithStack(err) } @@ -187,5 +211,11 @@ func handleUpdateWorkgroup(ctx context.Context, workgroupID string, changes mode return nil, errors.WithStack(err) } + eventRepo := model.NewEventRepository(db) + + if _, err := eventRepo.Add(ctx, user, model.EventTypeUpdated, workgroup); err != nil { + return nil, errors.WithStack(err) + } + return workgroup, nil } diff --git a/internal/model/dsf.go b/internal/model/dsf.go index a978b95..ee01bb5 100644 --- a/internal/model/dsf.go +++ b/internal/model/dsf.go @@ -1,19 +1,55 @@ package model import ( + "encoding/json" "time" "github.com/jinzhu/gorm" "github.com/jinzhu/gorm/dialects/postgres" + errs "github.com/pkg/errors" ) +const ObjectTypeDecisionSupportFile = "dsf" + type DecisionSupportFile struct { gorm.Model - Title string `json:"title"` - Sections postgres.Jsonb `json:"sections"` - Status string `json:"status"` - WorkgroupID uint `json:"-"` - Workgroup *Workgroup `json:"workgroup"` - VotedAt time.Time `json:"votedAt"` - ClosedAt time.Time `json:"closedAt"` + Title string `json:"title"` + SectionsJSON postgres.Jsonb `json:"-" gorm:"column:sections;"` + Sections map[string]interface{} `gorm:"-"` + Status string `json:"status"` + WorkgroupID uint `json:"-"` + Workgroup *Workgroup `json:"workgroup" gorm:"association_autoupdate:false"` + VotedAt time.Time `json:"votedAt"` + ClosedAt time.Time `json:"closedAt"` +} + +func (f *DecisionSupportFile) ObjectID() uint { + return f.ID +} + +func (f *DecisionSupportFile) ObjectType() string { + return ObjectTypeDecisionSupportFile +} + +func (f *DecisionSupportFile) BeforeSave() error { + rawSections, err := json.Marshal(f.Sections) + if err != nil { + return errs.WithStack(err) + } + + f.SectionsJSON = postgres.Jsonb{RawMessage: rawSections} + + return nil +} + +func (f *DecisionSupportFile) AfterFind() (err error) { + sections := make(map[string]interface{}) + + if err := json.Unmarshal(f.SectionsJSON.RawMessage, §ions); err != nil { + return errs.WithStack(err) + } + + f.Sections = sections + + return nil } diff --git a/internal/model/dsf_repository.go b/internal/model/dsf_repository.go index 1292458..ed0400d 100644 --- a/internal/model/dsf_repository.go +++ b/internal/model/dsf_repository.go @@ -2,11 +2,9 @@ package model import ( "context" - "encoding/json" "errors" "github.com/jinzhu/gorm" - "github.com/jinzhu/gorm/dialects/postgres" errs "github.com/pkg/errors" ) @@ -69,12 +67,7 @@ func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *Dec dsf.Workgroup = wg if changes.Sections != nil { - rawSections, err := json.Marshal(changes.Sections) - if err != nil { - return errs.WithStack(err) - } - - dsf.Sections = postgres.Jsonb{RawMessage: rawSections} + dsf.Sections = changes.Sections } if changes.Title != nil { diff --git a/internal/model/event.go b/internal/model/event.go new file mode 100644 index 0000000..164dc30 --- /dev/null +++ b/internal/model/event.go @@ -0,0 +1,31 @@ +package model + +import ( + "github.com/jinzhu/gorm" +) + +type EventType string + +const ( + EventTypeCreated EventType = "created" + EventTypeUpdated EventType = "updated" + EventTypeLeaved EventType = "leaved" + EventTypeJoined EventType = "joined" + EventTypeClosed EventType = "closed" + EventTypeStatusChanged EventType = "status-changed" + EventTypeTitleChanged EventType = "title-changed" +) + +type EventObject interface { + ObjectID() uint + ObjectType() string +} + +type Event struct { + gorm.Model + UserID uint `json:"-"` + User *User `json:"user" gorm:"association_autoupdate:false"` + ObjectType string `json:"objectType"` + ObjectID uint `json:"objectId"` + Type EventType `json:"type"` +} diff --git a/internal/model/event_repository.go b/internal/model/event_repository.go new file mode 100644 index 0000000..7cdede9 --- /dev/null +++ b/internal/model/event_repository.go @@ -0,0 +1,73 @@ +package model + +import ( + "context" + + "github.com/jinzhu/gorm" + errs "github.com/pkg/errors" +) + +type EventRepository struct { + db *gorm.DB +} + +func (r *EventRepository) Add(ctx context.Context, user *User, eventType EventType, obj EventObject) (*Event, error) { + evt := &Event{ + Type: eventType, + User: user, + ObjectID: obj.ObjectID(), + ObjectType: obj.ObjectType(), + } + + if err := r.db.Save(&evt).Error; err != nil { + return nil, errs.WithStack(err) + } + + return evt, nil +} + +func (r *EventRepository) Search(ctx context.Context, filter *EventFilter) ([]*Event, error) { + query := r.db.Model(&Event{}).Preload("User") + + if filter == nil { + filter = &EventFilter{} + } + + if filter.ObjectID != nil { + query = query.Where("object_id = ?", filter.ObjectID) + } + + if filter.ObjectType != nil { + query = query.Where("object_type = ?", filter.ObjectType) + } + + if filter.UserID != nil { + query = query.Where("user_id = ?", filter.UserID) + } + + if filter.Type != nil { + query = query.Where("type = ?", filter.Type) + } + + if filter.From != nil { + query = query.Where("created_at >= ?", filter.From) + } + + if filter.To != nil { + query = query.Where("created_at <= ?", filter.To) + } + + query = query.Order("created_at DESC") + + events := make([]*Event, 0) + + if err := query.Find(&events).Error; err != nil { + return nil, errs.WithStack(err) + } + + return events, nil +} + +func NewEventRepository(db *gorm.DB) *EventRepository { + return &EventRepository{db} +} diff --git a/internal/model/user.go b/internal/model/user.go index 79a88e0..a5d6f30 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -11,7 +11,7 @@ type User struct { Name *string `json:"name"` Email string `json:"email" gorm:"unique;not null"` ConnectedAt time.Time `json:"connectedAt"` - Workgroups []*Workgroup `gorm:"many2many:users_workgroups;"` + Workgroups []*Workgroup `gorm:"many2many:users_workgroups;association_autoupdate:false"` } type ProfileChanges struct { diff --git a/internal/model/workgroup.go b/internal/model/workgroup.go index 64b2c0a..7f71eae 100644 --- a/internal/model/workgroup.go +++ b/internal/model/workgroup.go @@ -6,11 +6,21 @@ import ( "github.com/jinzhu/gorm" ) +const ObjectTypeWorkgroup = "workgroup" + type Workgroup struct { gorm.Model Name *string `json:"name"` ClosedAt time.Time `json:"closedAt"` - Members []*User `gorm:"many2many:users_workgroups;"` + Members []*User `gorm:"many2many:users_workgroups;association_autoupdate:false"` +} + +func (w *Workgroup) ObjectID() uint { + return w.ID +} + +func (w *Workgroup) ObjectType() string { + return ObjectTypeWorkgroup } type WorkgroupChanges struct { diff --git a/internal/model/workgroup_repository.go b/internal/model/workgroup_repository.go index 6f83de0..546b733 100644 --- a/internal/model/workgroup_repository.go +++ b/internal/model/workgroup_repository.go @@ -106,29 +106,35 @@ func (r *WorkgroupRepository) AddUserToWorkgroup(ctx context.Context, userID, wo } func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userID, workgroupID uint) (*Workgroup, error) { - user := &User{} - - err := r.db.First(user, "id = ?", userID).Error - if err != nil { - return nil, errors.Wrap(err, "could not find user") - } - workgroup := &Workgroup{} workgroup.ID = workgroupID - err = r.db.Model(user). - Association("Workgroups"). - Delete(workgroup). - Error + err := r.db.Transaction(func(tx *gorm.DB) error { + user := &User{} + err := tx.First(user, "id = ?", userID).Error + if err != nil { + return errors.Wrap(err, "could not find user") + } - if err != nil { - return nil, errors.Wrap(err, "could not add user to workgroup") - } + err = tx.Model(user). + Association("Workgroups"). + Delete(workgroup). + Error - err = r.db.Model(workgroup). - Preload("Members"). - First(workgroup, "id = ?", workgroupID). - Error + if err != nil { + return errors.Wrap(err, "could not add user to workgroup") + } + + err = tx.Model(workgroup). + Preload("Members"). + First(workgroup, "id = ?", workgroupID). + Error + if err != nil { + return errors.WithStack(err) + } + + return nil + }) if err != nil { return nil, errors.WithStack(err) }