Création/mise à jour basique d'un DAD #15

Manually merged
wpetit merged 7 commits from feature/dad into develop 2020-08-26 14:52:54 +02:00
49 changed files with 1481 additions and 172 deletions

View File

@ -1,20 +1,49 @@
import React from 'react';
import React, { FunctionComponent, useState } from 'react';
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage';
import { ProfilePage } from './ProfilePage/ProfilePage';
import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage';
import { DashboardPage } from './DashboardPage/DashboardPage';
import { useUserProfile } from '../gql/queries/profile';
import { LoggedInContext } from '../hooks/useLoggedIn';
import { PrivateRoute } from './PrivateRoute';
import { useKonamiCode } from '../hooks/useKonamiCode';
import { Modal } from './Modal';
export class App extends React.Component {
render() {
return (
export interface AppProps {
}
export const App: FunctionComponent<AppProps> = () => {
const { user } = useUserProfile();
const [ showBoneyM, setShowBoneyM ] = useState(false);
useKonamiCode(() => setShowBoneyM(true));
return (
<LoggedInContext.Provider value={user.id !== ''}>
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<Route path="/profile" exact component={ProfilePage} />
<Route path="/workgroups/:id" exact component={WorkgroupPage} />
<PrivateRoute path="/profile" exact component={ProfilePage} />
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
<PrivateRoute path="/decisions/:id" exact component={DecisionSupportFilePage} />
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
);
}
{
showBoneyM ?
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
<iframe width={560} height={315}
frameBorder={0}
allowFullScreen={true}
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
</iframe>
</Modal> :
null
}
</LoggedInContext.Provider>
);
}

View File

@ -1,26 +1,17 @@
import React from 'react';
import { WorkgroupsPanel } from './WorkgroupsPanel';
import { DecisionSupportFilePanel } from './DecisionSupportFilePanel';
export function Dashboard() {
return (
<div className="columns">
<div className="column">
<div className="column is-6">
<DecisionSupportFilePanel />
</div>
<div className="column is-3">
<WorkgroupsPanel />
</div>
<div className="column">
<div className="box">
<div className="level">
<div className="level-left">
<h3 className="is-size-3 subtitle level-item">D.A.Ds</h3>
</div>
<div className="level-right">
<button disabled className="button is-primary level-item"><i className="fa fa-plus"></i></button>
</div>
</div>
<pre>TODO</pre>
</div>
</div>
<div className="column">
<div className="column is-3">
<div className="box">
<div className="level">
<div className="level-left">

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Page } from '../Page';
import { Dashboard } from './Dashboard';
export function DashboardPage() {
return (
<Page title={'Tableau de bord'}>
<div className="container is-fluid">
<section className="mt-5">
<Dashboard />
</section>
</div>
</Page>
);
}

View File

@ -0,0 +1,44 @@
import React from 'react';
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { ItemPanel, TabDefinition, Item } from './ItemPanel';
import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
export function DecisionSupportFilePanel() {
const { user } = useUserProfile();
const { decisionSupportFiles } = useDecisionSupportFiles();
const tabs: TabDefinition[] = [
{
label: 'Mes dossiers en cours',
itemFilter: (item: Item) => {
const dsf = item as DecisionSupportFile;
return (dsf.status === DecisionSupportFileStatus.Draft || dsf.status === DecisionSupportFileStatus.Ready) && inWorkgroup(user, dsf.workgroup);
}
},
{
label: 'Brouillons',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Draft
},
{
label: 'Clos',
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Closed
},
];
return (
<ItemPanel
className='is-link'
title="Dossiers"
newItemUrl="/decisions/new"
items={decisionSupportFiles}
tabs={tabs}
itemIconClassName='fas fa-folder'
itemKey={item => item.id}
itemLabel={item => item.title}
itemUrl={item => `/decisions/${item.id}`}
/>
);
}

View File

@ -0,0 +1,121 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { WithLoader } from "../WithLoader";
export interface Item {
id: string
[propName: string]: any;
}
export interface TabDefinition {
label: string
itemFilter?: (item: Item) => boolean
}
export interface ItemPanelProps {
className?: string
itemIconClassName?: string
title?: string
newItemUrl: string
isLoading?: boolean
items: Item[]
tabs?: TabDefinition[],
itemKey: (item: Item, index: number) => string
itemLabel: (item: Item, index: number) => string
itemUrl: (item: Item, index: number) => string
}
export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
const {
title, className, newItemUrl,
itemKey, itemLabel,
itemIconClassName, itemUrl
} = props;
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
const itemFilter = tab && typeof tab.itemFilter === 'function' ? tab.itemFilter : () => true;
return items.filter(itemFilter);
};
const selectTab = (tabIndex: number) => {
setState(state => {
const { tabs, items } = props;
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
return {
...state,
selectedTab: tabIndex,
filteredItems: filterItemsForTab(newTab, items)
};
});
};
useEffect(() => {
setState(state => {
const { tabs, items } = props;
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[state.selectedTab] : null;
return {
...state,
filteredItems: filterItemsForTab(newTab, items),
}
});
}, [props.items, props.tabs]);
const itemElements = state.filteredItems.map((item: Item, i: number) => {
return (
<Link to={itemUrl(item, i)} key={`item-${itemKey(item, i)}`} className="panel-block">
<span className="panel-icon">
<i className={itemIconClassName} aria-hidden="true"></i>
</span>
{itemLabel(item, i)}
</Link>
);
});
const tabs = props.tabs || [];
return (
<nav className={`panel ${className}`}>
<div className="level is-mobile panel-heading mb-0">
<div className="level-left">
<p className="level-item">{title}</p>
</div>
<div className="level-right">
<Link to={newItemUrl} className="button level-item is-outlined is-info is-inverted">
<i className="icon fa fa-plus"></i>
</Link>
</div>
</div>
<div className="panel-block">
<p className="control has-icons-left">
<input className="input" type="text" placeholder="Filtrer..." />
<span className="icon is-left">
<i className="fas fa-search" aria-hidden="true"></i>
</span>
</p>
</div>
<p className="panel-tabs">
{
tabs.map((tab, i) => {
return (
<a key={`workgroup-tab-${i}`}
onClick={selectTab.bind(null, i)}
className={i === state.selectedTab ? 'is-active' : ''}>
{tab.label}
</a>
)
})
}
</p>
{
itemElements.length > 0 ?
itemElements :
<a className="panel-block has-text-centered is-block">
<em>Aucun élément pour l'instant.</em>
</a>
}
</nav>
)
};

View File

@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { Workgroup, inWorkgroup } from '../../types/workgroup';
import { User } from '../../types/user';
import { Link } from 'react-router-dom';
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile';
import { WithLoader } from '../WithLoader';
import { ItemPanel, Item } from './ItemPanel';
export function WorkgroupsPanel() {
const { workgroups } = useWorkgroups();
const { user } = useUserProfile();
const tabs = [
{
label: "Mes groupes en cours",
itemFilter: (item: Item) => {
const wg = item as Workgroup;
return wg.closedAt === null && inWorkgroup(user, wg);
}
},
{
label: "Ouverts",
itemFilter: (item: Item) => !(item as Workgroup).closedAt
},
{
label: "Clos",
itemFilter: (item: Item) => !!(item as Workgroup).closedAt
}
];
return (
<ItemPanel
className='is-info'
title="Groupes de travail"
newItemUrl="/workgroups/new"
items={workgroups}
tabs={tabs}
itemIconClassName='fas fa-users'
itemKey={item => item.id}
itemLabel={item => item.name}
itemUrl={item => `/workgroups/${item.id}`}
/>
);
}

View File

@ -0,0 +1,18 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
export interface AppendixPanelProps {
dsf: DecisionSupportFile,
};
export const AppendixPanel: FunctionComponent<AppendixPanelProps> = ({ dsf }) => {
return (
<nav className="panel">
<p className="panel-heading">
Annexes
</p>
<div className="panel-block">
</div>
</nav>
);
};

View File

@ -0,0 +1,134 @@
import React, { FunctionComponent, useState, ChangeEvent, useEffect } from 'react';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { useDebounce } from '../../hooks/useDebounce';
import { asDate } from '../../util/date';
export interface ClarificationSectionProps extends DecisionSupportFileUpdaterProps {};
const ClarificationSectionName = 'clarification';
export const ClarificationSection: FunctionComponent<ClarificationSectionProps> = ({ dsf, updateDSF }) => {
const [ state, setState ] = useState({
changed: false,
section: {
objectives: '',
motivations: '',
scope: '',
nature: '',
deadline: undefined,
hasDeadline: false,
}
});
useEffect(() => {
if (!state.changed) return;
updateDSF({ ...dsf, sections: { ...dsf.sections, [ClarificationSectionName]: { ...state.section }} })
setState(state => ({ ...state, changed: false }));
}, [state.changed]);
useEffect(() => {
if (!dsf.sections[ClarificationSectionName]) return;
setState(state => ({ ...state, changed: false, section: {...state.section, ...dsf.sections[ClarificationSectionName] }}));
}, [dsf.sections[ClarificationSectionName]]);
const onTitleChange = (evt: ChangeEvent<HTMLInputElement>) => {
const title = (evt.currentTarget).value;
updateDSF({ ...dsf, title });
};
const onSectionAttrChange = (attrName: string, evt: ChangeEvent<HTMLInputElement>) => {
const target = evt.currentTarget;
const value = target.hasOwnProperty('checked') ? target.checked : target.value;
setState(state => ({ ...state, changed: true, section: {...state.section, [attrName]: value }}));
};
const onDeadlineChange = (evt: ChangeEvent<HTMLInputElement>) => {
const deadline = evt.currentTarget.valueAsDate;
setState(state => ({ ...state, changed: true, section: { ...state.section, deadline }}));
};
return (
<section>
<div className="box">
<div className="field">
<label className="label">Intitulé du dossier</label>
<div className="control">
<input className="input" type="text" value={dsf.title} onChange={onTitleChange} />
</div>
</div>
<div className="field">
<label className="label">Quelle décision devons nous prendre ?</label>
<div className="control">
<textarea className="textarea"
value={state.section.objectives}
onChange={onSectionAttrChange.bind(null, 'objectives')}
placeholder="Décrire globalement les tenants et aboutissants de la décision à prendre."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Ne pas essayer de rentrer trop dans les détails ici. Préférer l'utilisation des annexes et y faire référence.</p>
</div>
<div className="field">
<label className="label">Pourquoi devons nous prendre cette décision ?</label>
<div className="control">
<textarea className="textarea"
value={state.section.motivations}
onChange={onSectionAttrChange.bind(null, 'motivations')}
placeholder="Décrire pourquoi il est important de prendre cette décision."
rows={10}>
</textarea>
</div>
<p className="help is-info"><i className="fa fa-info-circle"></i> Penser à indiquer si des obligations légales pèsent sur cette prise de décision.</p>
</div>
<div className="field">
<label className="label">Portée de la décision</label>
<div className="control">
<div className="select">
<select
onChange={onSectionAttrChange.bind(null, 'scope')}
value={state.section.scope}>
<option></option>
<option value="individual">Individuelle</option>
<option value="identified-group">Groupe identifié</option>
<option value="collective">Collective</option>
</select>
</div>
</div>
</div>
<div className="field">
<label className="label">Nature de la décision</label>
<div className="control">
<div className="select">
<select onChange={onSectionAttrChange.bind(null, 'nature')}
value={state.section.nature}>
<option></option>
<option value="operational">Opérationnelle</option>
<option value="tactic">Tactique</option>
<option value="strategic">Stratégique</option>
</select>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<label className="checkbox">
<input type="checkbox"
onChange={onSectionAttrChange.bind(null, 'hasDeadline')}
checked={state.section.hasDeadline} />
<span className="ml-1">Existe t'il une échéance particulière pour cette décision ?</span>
</label>
<div className="field">
<div className="control">
<input disabled={!state.section.hasDeadline}
value={state.section.deadline ? asDate(state.section.deadline).toISOString().substr(0, 10) : ''}
onChange={onDeadlineChange}
type="date" className="input" />
</div>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,155 @@
import React, { FunctionComponent, useState, useEffect } from 'react';
import { Page } from '../Page';
import { ClarificationSection } from './ClarificationSection';
import { MetadataPanel } from './MetadataPanel';
import { AppendixPanel } from './AppendixPanel';
import { DecisionSupportFile, newDecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { useParams, useHistory } from 'react-router';
import { useDecisionSupportFiles } from '../../gql/queries/dsf';
import { useCreateDecisionSupportFileMutation, useUpdateDecisionSupportFileMutation } from '../../gql/mutations/dsf';
import { useDebounce } from '../../hooks/useDebounce';
export interface DecisionSupportFilePageProps {
};
export const DecisionSupportFilePage: FunctionComponent<DecisionSupportFilePageProps> = () => {
const { id } = useParams();
const history = useHistory();
const { decisionSupportFiles } = useDecisionSupportFiles({
variables:{
filter: {
ids: id !== 'new' ? [id] : undefined,
}
}
});
const [ state, setState ] = useState({
dsf: newDecisionSupportFile(),
saved: true,
selectedTabIndex: 0,
});
useEffect(() => {
const dsf = decisionSupportFiles.length > 0 && decisionSupportFiles[0].id === id ? decisionSupportFiles[0] : {};
setState(state => ({ ...state, dsf: { ...state.dsf, ...dsf }}))
}, [ decisionSupportFiles ]);
const selectTab = (tabIndex: number) => {
setState(state => ({ ...state, selectedTabIndex: tabIndex }));
};
const updateDSF = (dsf: DecisionSupportFile) => {
setState(state => {
return { ...state, saved: false, dsf: { ...state.dsf, ...dsf } };
});
};
const [ createDecisionSupportFile ] = useCreateDecisionSupportFileMutation();
const [ updateDecisionSupportFile ] = useUpdateDecisionSupportFileMutation();
const saveDSF = () => {
const changes = {
title: state.dsf.title !== '' ? state.dsf.title : undefined,
status: state.dsf.status,
workgroupId: state.dsf.workgroup ? state.dsf.workgroup.id : undefined,
sections: state.dsf.sections,
};
if (!changes.workgroupId) return;
if (state.dsf.id === '') {
createDecisionSupportFile({
variables: { changes },
}).then(({ data }) => {
history.push(`/decisions/${data.createDecisionSupportFile.id}`);
});
} else {
updateDecisionSupportFile({
variables: { changes, id: state.dsf.id },
}).then(({ data }) => {
setState(state => {
return { ...state, saved: true, dsf: { ...state.dsf, ...data.updateDecisionSupportFile } };
});
});
}
};
const canSave = !!state.dsf.workgroup && !state.saved;
const isNew = state.dsf.id === '';
const isClosed = state.dsf.status === DecisionSupportFileStatus.Closed;
return (
<Page title="Dossier d'Aide à la Décision">
<div className="container is-fluid">
<section className="mt-5">
<div className="level">
<div className="level-left">
{
isNew ?
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">Nouveau</h2>
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision</h3>
</div>
</div> :
<div className="level-item">
<div>
<h2 className="is-size-3 title is-spaced">{state.dsf.title}</h2>
<h3 className="is-size-5 subtitle">Dossier d'Aide à la Décision <span className="is-italic">{ isClosed ? '(clos)' : null }</span></h3>
</div>
</div>
}
</div>
<div className="level-right">
<div className="level-item buttons">
<button className="button is-medium is-success" disabled={!canSave} onClick={saveDSF}>
<span className="icon"><i className="fa fa-save"></i></span>
<span>Enregistrer</span>
</button>
</div>
</div>
</div>
</section>
<div className="columns mt-3">
<div className="column is-9">
<div className="tabs is-medium is-toggle">
<ul>
<li className={`has-background-white ${state.selectedTabIndex === 0 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 0)}>
<a>
<span className="icon is-small"><i className="fas fa-pen" aria-hidden="true"></i></span>
<span>Clarifier la proposition</span>
</a>
</li>
<li className={`has-background-white ${state.selectedTabIndex === 1 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 1)}>
<a>
<span className="icon is-small"><i className="fas fa-search" aria-hidden="true"></i></span>
<span>Explorer les options</span>
</a>
</li>
<li className={`has-background-white ${state.selectedTabIndex === 2 ? 'is-active': ''}`}
onClick={selectTab.bind(null, 2)}>
<a>
<span className="icon is-small"><i className="fas fa-person-booth" aria-hidden="true"></i></span>
<span>Prendre la décision</span>
</a>
</li>
</ul>
</div>
{
state.selectedTabIndex === 0 ?
<ClarificationSection dsf={state.dsf} updateDSF={updateDSF} /> :
null
}
</div>
<div className="column is-3">
<MetadataPanel dsf={state.dsf} updateDSF={updateDSF} />
<AppendixPanel dsf={state.dsf} />
</div>
</div>
</div>
</Page>
);
};

View File

@ -0,0 +1,6 @@
import { DecisionSupportFile } from "../../types/decision";
export interface DecisionSupportFileUpdaterProps {
dsf: DecisionSupportFile
updateDSF: (dsf: DecisionSupportFile) => void
}

View File

@ -0,0 +1,86 @@
import React, { FunctionComponent, useState, useEffect, ChangeEvent } from 'react';
import { DecisionSupportFile, DecisionSupportFileStatus } from '../../types/decision';
import { useWorkgroups } from '../../gql/queries/workgroups';
import { useUserProfile } from '../../gql/queries/profile';
import { inWorkgroup } from '../../types/workgroup';
import { DecisionSupportFileUpdaterProps } from './DecisionSupportFileUpdaterProps';
import { asDate } from '../../util/date';
export interface MetadataPanelProps extends DecisionSupportFileUpdaterProps {};
export const MetadataPanel: FunctionComponent<MetadataPanelProps> = ({ dsf, updateDSF }) => {
const { user } = useUserProfile();
const { workgroups } = useWorkgroups();
const [ userOpenedWorkgroups, setUserOpenedWorkgroups ] = useState([]);
useEffect(() => {
const filtered = workgroups.filter(wg => !wg.closedAt && inWorkgroup(user, wg));
setUserOpenedWorkgroups(filtered);
}, [workgroups, user])
const onStatusChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
const status = evt.currentTarget.value as DecisionSupportFileStatus;
updateDSF({ ...dsf, status });
};
const onWorkgroupChanged = (evt: ChangeEvent<HTMLSelectElement>) => {
const workgroupId = evt.currentTarget.value;
const workgroup = workgroups.find(wg => wg.id === workgroupId);
updateDSF({ ...dsf, workgroup });
};
return (
<nav className="panel">
<p className="panel-heading">
Métadonnées
</p>
<div className="panel-block">
<div style={{width:'100%'}}>
<div className="field">
<div className="label">Groupe de travail</div>
<div className="control is-expanded">
<div className="select is-fullwidth">
<select onChange={onWorkgroupChanged} value={dsf.workgroup ? dsf.workgroup.id : ''}>
<option></option>
{
userOpenedWorkgroups.map(wg => {
return (
<option key={`wg-${wg.id}`} value={wg.id}>{wg.name}</option>
);
})
}
</select>
</div>
</div>
</div>
<div className="field">
<div className="label">Statut</div>
<div className="control is-expanded">
<div className="select is-fullwidth">
<select onChange={onStatusChanged} value={dsf.status}>
<option value="draft">Brouillon</option>
<option value="ready">Prêt à voter</option>
<option value="voted">Voté</option>
<option value="closed">Clôs</option>
</select>
</div>
</div>
</div>
<div className="field">
<div className="label">Créé le</div>
<div className="control">
<p>{asDate(dsf.createdAt).toISOString()}</p>
</div>
</div>
<div className="field">
<div className="label">Voté le</div>
<div className="control">
<p>{dsf.votedAt ? dsf.votedAt : '--'}</p>
</div>
</div>
</div>
</div>
</nav>
);
};

View File

@ -0,0 +1,17 @@
import React, { FunctionComponent, useState } from 'react';
import { DecisionSupportFile } from '../../types/decision';
export interface OptionsSectionProps {
dsf: DecisionSupportFile,
};
export const OptionsSection: FunctionComponent<OptionsSectionProps> = ({ dsf }) => {
return (
<section>
<h4 id="options-section" className="is-size-4 title is-spaced"><a href="#options-section">Explorer les options</a></h4>
<div className="box">
</div>
</section>
);
};

View File

@ -1,33 +1,20 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { Dashboard } from './Dashboard';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { WithLoader } from '../WithLoader';
import { WelcomeContent } from './WelcomeContent';
import { useUserProfile } from '../../gql/queries/profile';
import { useHistory } from 'react-router';
export function HomePage() {
const { data, loading } = useUserProfileQuery();
const { user } = useUserProfile();
const history = useHistory();
const { userProfile } = (data || {});
useEffect(() => {
if (user.id !== '') history.push('/dashboard');
}, [user.id])
return (
<Page title={userProfile ? 'Tableau de bord' : 'Accueil'}>
<div className="container is-fluid">
<section className="mt-5">
<WithLoader loading={loading}>
{
userProfile ?
<Dashboard /> :
<div className="columns">
<div className="column is-4 is-offset-4">
<div className="box">
<p>Veuillez vous authentifier.</p>
</div>
</div>
</div>
}
</WithLoader>
</section>
</div>
<Page title="Accueil">
<WelcomeContent />
</Page>
);
}

View File

@ -0,0 +1,75 @@
import React, { FunctionComponent, Fragment } from "react";
export interface WelcomeContentProps {
}
export const WelcomeContent: FunctionComponent<WelcomeContentProps> = () => {
return (
<Fragment>
<section className="hero is-normal is-light is-bold">
<div className="hero-body has-text-centered">
<div className="container">
<h1 className="title">
Bienvenue sur Daddy !
</h1>
<h2 className="subtitle">
L'outil de suivi de la vie d'entreprise démocratique.
</h2>
</div>
</div>
</section>
<div className="box cta">
<p className="has-text-centered">
<span className="tag is-info">Attention</span> Le service est actuellement en alpha. L'accès est restreint aux adresses autorisées.
</p>
</div>
<section className="container mt-5">
<div className="columns">
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-at mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Une adresse courriel et c'est parti !</h4>
<p>Pas de création de compte, pas de mot de passe à retenir. Entrez votre adresse courriel et commencez directement à travailler !</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-edit mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Préparer vos dossiers d'aide à la décision</h4>
<p>Une décision à prendre ? Un nouveau projet à lancer ? Crééz votre groupe de travail et rédigez un dossier pour faciliter la prise de décision collective !</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
<div className="column is-4">
<div className="card is-shady">
<div className="card-image has-text-centered">
<i className="fa fa-users mt-5" style={{fontSize: '8rem'}}></i>
</div>
<div className="card-content">
<div className="content">
<h4>Travaillez collaborativement</h4>
<p>Éditez à plusieurs vos dossiers d'aide à la décision, suivi l'avancée des débats et retrouvez simplement les décisions prises dans l'entreprise.</p>
{/* <p><a href="#">En savoir plus</a></p> */}
</div>
</div>
</div>
</div>
</div>
</section>
</Fragment>
);
};

View File

@ -1,98 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Workgroup } from '../../types/workgroup';
import { User } from '../../types/user';
import { Link } from 'react-router-dom';
import { useWorkgroupsQuery } from '../../gql/queries/workgroups';
import { useUserProfileQuery } from '../../gql/queries/profile';
import { WithLoader } from '../WithLoader';
export function WorkgroupsPanel() {
const workgroupsQuery = useWorkgroupsQuery();
const userProfileQuery = useUserProfileQuery();
const [ state, setState ] = useState({ selectedTab: 0 });
const isLoading = userProfileQuery.loading || workgroupsQuery.loading;
const { userProfile } = (userProfileQuery.data || {});
const { workgroups } = (workgroupsQuery.data || {});
const filterTabs = [
{
label: "Mes groupes en cours",
filter: workgroups => workgroups.filter((wg: Workgroup) => {
return wg.closedAt === null && wg.members.some((u: User) => u.id === (userProfile ? userProfile.id : ''));
})
},
{
label: "Ouverts",
filter: workgroups => workgroups.filter((wg: Workgroup) => !wg.closedAt)
},
{
label: "Clos",
filter: workgroups => workgroups.filter((wg: Workgroup) => !!wg.closedAt)
}
];
const selectTab = (tabIndex: number) => {
setState(state => ({ ...state, selectedTab: tabIndex }));
};
let workgroupsItems = [];
workgroupsItems = filterTabs[state.selectedTab].filter(workgroups || []).map((wg: Workgroup) => {
return (
<Link to={`/workgroups/${wg.id}`} key={`wg-${wg.id}`} className="panel-block">
<span className="panel-icon">
<i className="fas fa-users" aria-hidden="true"></i>
</span>
{wg.name}
</Link>
);
});
return (
<nav className="panel is-info">
<div className="level panel-heading mb-0">
<div className="level-left">
<p className="level-item">
Groupes de travail
</p>
</div>
<div className="level-right">
<Link to="/workgroups/new" className="button level-item is-outlined is-info is-inverted">
<i className="icon fa fa-plus"></i>
</Link>
</div>
</div>
{/* <div className="panel-block">
<p className="control has-icons-left">
<input className="input" type="text" placeholder="Filtrer..." />
<span className="icon is-left">
<i className="fas fa-search" aria-hidden="true"></i>
</span>
</p>
</div> */}
<WithLoader loading={isLoading}>
<p className="panel-tabs">
{
filterTabs.map((tab, i) => {
return (
<a key={`workgroup-tab-${i}`}
onClick={selectTab.bind(null, i)}
className={i === state.selectedTab ? 'is-active' : ''}>
{tab.label}
</a>
)
})
}
</p>
{
workgroupsItems.length > 0 ?
workgroupsItems :
<a className="panel-block has-text-centered is-block">
<em>Aucun groupe dans cet catégorie pour l'instant.</em>
</a>
}
</WithLoader>
</nav>
)
}

View File

@ -3,11 +3,10 @@ import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { Config } from '../config';
import { Link } from 'react-router-dom';
import { useUserProfileQuery } from '../gql/queries/profile';
import { WithLoader } from './WithLoader';
import { useLoggedIn } from '../hooks/useLoggedIn';
export function Navbar() {
const userProfileQuery = useUserProfileQuery();
const loggedIn = useLoggedIn();
const [ isActive, setActive ] = useState(false);
const toggleMenu = () => {
@ -15,7 +14,7 @@ export function Navbar() {
};
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<nav className="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<Link className="navbar-item" to="/">
@ -35,30 +34,30 @@ export function Navbar() {
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-end">
<div className="navbar-item">
<WithLoader loading={userProfileQuery.loading}>
<div className="buttons">
<div className="buttons">
{
userProfileQuery.data && userProfileQuery.data.userProfile ?
loggedIn ?
<Fragment>
<Link to="/profile" className="button">
<span className="icon">
<i className="fas fa-user"></i>
</span>
<span>Mon profil</span>
</Link>
<a className="button" href={Config.logoutURL}>
<a className="button is-warning" href={Config.logoutURL}>
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
</a>
</Fragment> :
<a className="button" href={Config.loginURL}>
<a className="button is-primary" href={Config.loginURL}>
<span className="icon">
<i className="fas fa-sign-in-alt"></i>
</span>
<span>S'identifier</span>
</a>
}
</div>
</WithLoader>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
import React, { FunctionComponent, Component, ReactType } from "react"
import { Route, Redirect, RouteProps } from "react-router"
import { useLoggedIn } from "../hooks/useLoggedIn";
export interface PrivateRouteProps extends RouteProps {
}
export const PrivateRoute: FunctionComponent<PrivateRouteProps> = ({component: Component, ...rest}) => {
const loggedIn = useLoggedIn();
return (
<Route
{...rest}
render={(props) => loggedIn === true
? <Component {...props} />
: <Redirect to={{pathname: '/', state: {from: props.location}}} />}
/>
)
}

View File

@ -3,6 +3,7 @@ import { Config } from '../config';
import { WebSocketLink } from "@apollo/client/link/ws";
import { RetryLink } from "@apollo/client/link/retry";
import { SubscriptionClient } from "subscriptions-transport-ws";
import { User } from '../types/user';
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
reconnect: true,
@ -14,7 +15,42 @@ const link = new RetryLink({attempts: {max: 2}}).split(
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
);
const cache = new InMemoryCache({
typePolicies: {
Workgroup: {
fields: {
members: {
merge: mergeArrayByField<User>("id"),
}
}
}
}
});
export const client = new ApolloClient<any>({
cache: new InMemoryCache(),
cache: cache,
link: link,
});
});
function mergeArrayByField<T>(fieldName: string) {
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
const merged: any[] = existing ? existing.slice(0) : [];
const objectFieldToIndex: Record<string, number> = Object.create(null);
if (existing) {
existing.forEach((obj, index) => {
objectFieldToIndex[readField(fieldName, obj)] = index;
});
}
incoming.forEach(obj => {
const field = readField(fieldName, obj);
const index = objectFieldToIndex[field];
if (typeof index === "number") {
merged[index] = mergeObjects(merged[index], obj);
} else {
objectFieldToIndex[name] = merged.length;
merged.push(obj);
}
});
return merged;
}
}

View File

@ -0,0 +1,40 @@
import { gql, useQuery, useMutation } from '@apollo/client';
import { QUERY_DECISION_SUPPORT_FILES } from '../queries/dsf';
export const MUTATION_CREATE_DECISION_SUPPORT_FILE = gql`
mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) {
createDecisionSupportFile(changes: $changes) {
id,
title,
status,
sections,
createdAt,
updatedAt
}
}`;
export function useCreateDecisionSupportFileMutation() {
return useMutation(MUTATION_CREATE_DECISION_SUPPORT_FILE, {
refetchQueries: [{query: QUERY_DECISION_SUPPORT_FILES}],
});
}
export const MUTATION_UPDATE_DECISION_SUPPORT_FILE = gql`
mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChanges!) {
updateDecisionSupportFile(id: $id, changes: $changes) {
id,
title,
status,
sections,
createdAt,
updatedAt
}
}`;
export function useUpdateDecisionSupportFileMutation() {
return useMutation(MUTATION_UPDATE_DECISION_SUPPORT_FILE, {
refetchQueries: [{
query: QUERY_DECISION_SUPPORT_FILES,
}],
});
}

View File

@ -1,6 +1,6 @@
import { gql, useQuery, useMutation } from '@apollo/client';
const MUTATION_UPDATE_USER_PROFILE = gql`
export const MUTATION_UPDATE_USER_PROFILE = gql`
mutation updateUserProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
id,

View File

@ -1,6 +1,7 @@
import { gql, useQuery, useMutation } from '@apollo/client';
import { QUERY_WORKGROUP } from '../queries/workgroups';
const MUTATION_UPDATE_WORKGROUP = gql`
export const MUTATION_UPDATE_WORKGROUP = gql`
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
updateWorkgroup(workgroupId: $workgroupId, changes: $changes) {
id,
@ -19,7 +20,7 @@ export function useUpdateWorkgroupMutation() {
return useMutation(MUTATION_UPDATE_WORKGROUP);
}
const MUTATION_CREATE_WORKGROUP = gql`
export const MUTATION_CREATE_WORKGROUP = gql`
mutation createWorkgroup($changes: WorkgroupChanges!) {
createWorkgroup(changes: $changes) {
id,
@ -35,10 +36,12 @@ mutation createWorkgroup($changes: WorkgroupChanges!) {
}`;
export function useCreateWorkgroupMutation() {
return useMutation(MUTATION_CREATE_WORKGROUP);
return useMutation(MUTATION_CREATE_WORKGROUP, {
refetchQueries: [{query: QUERY_WORKGROUP}],
});
}
const MUTATION_JOIN_WORKGROUP = gql`
export const MUTATION_JOIN_WORKGROUP = gql`
mutation joinWorkgroup($workgroupId: ID!) {
joinWorkgroup(workgroupId: $workgroupId) {
id,

View File

@ -0,0 +1,35 @@
import { gql, useQuery } from '@apollo/client';
import { DecisionSupportFile } from '../../types/decision';
import { useGraphQLData } from './helper';
export const QUERY_DECISION_SUPPORT_FILES = gql`
query decisionSupportFiles($filter: DecisionSupportFileFilter) {
decisionSupportFiles(filter: $filter) {
id,
title,
sections
createdAt,
closedAt,
votedAt,
status,
workgroup {
id,
name,
members {
id
}
}
}
}
`;
export function useDecisionSupportFilesQuery(options = {}) {
return useQuery(QUERY_DECISION_SUPPORT_FILES, options);
}
export function useDecisionSupportFiles(options = {}) {
const { data, loading, error } = useGraphQLData<DecisionSupportFile[]>(
QUERY_DECISION_SUPPORT_FILES, 'decisionSupportFiles', [], options
);
return { decisionSupportFiles: data, loading, error };
}

View File

@ -0,0 +1,11 @@
import { useQuery, DocumentNode } from "@apollo/client";
import { useState, useEffect } from "react";
export function useGraphQLData<T>(q: DocumentNode, key: string, defaultValue: T, options = {}) {
const query = useQuery(q, options);
const [ data, setData ] = useState<T>(defaultValue);
useEffect(() => {
setData(query.data ? query.data[key] as T : defaultValue);
}, [query.loading, query.data]);
return { data, loading: query.loading, error: query.error };
}

View File

@ -1,6 +1,9 @@
import { gql, useQuery } from '@apollo/client';
import { User } from '../../types/user';
import { useState, useEffect } from 'react';
import { useGraphQLData } from './helper';
const QUERY_USER_PROFILE = gql`
export const QUERY_USER_PROFILE = gql`
query userProfile {
userProfile {
id,
@ -13,4 +16,11 @@ query userProfile {
export function useUserProfileQuery() {
return useQuery(QUERY_USER_PROFILE);
}
export function useUserProfile() {
const { data, loading, error } = useGraphQLData<User>(
QUERY_USER_PROFILE, 'userProfile', {id: '', email: ''}
);
return { user: data, loading, error };
}

View File

@ -1,6 +1,8 @@
import { gql, useQuery } from '@apollo/client';
import { Workgroup } from '../../types/workgroup';
import { useGraphQLData } from './helper';
const QUERY_WORKGROUP = gql`
export const QUERY_WORKGROUP = gql`
query workgroups($filter: WorkgroupsFilter) {
workgroups(filter: $filter) {
id,
@ -18,4 +20,12 @@ const QUERY_WORKGROUP = gql`
export function useWorkgroupsQuery(options = {}) {
return useQuery(QUERY_WORKGROUP, options);
}
export function useWorkgroups(options = {}) {
const { data, loading, error } = useGraphQLData<Workgroup[]>(
QUERY_WORKGROUP, 'workgroups', [],
options
);
return { workgroups: data, loading, error };
}

View File

@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay]
);
return debouncedValue;
}

View File

@ -0,0 +1,20 @@
import { useEffect } from "react";
export function useKonamiCode(cb: Function) {
const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
let cursor = 0;
useEffect(()=> {
const onKeyDown = (e) => {
cursor = (e.keyCode == KONAMI_CODE[cursor]) ? cursor + 1 : 0;
if (cursor == KONAMI_CODE.length) {
cb();
cursor = 0;
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
}

View File

@ -0,0 +1,7 @@
import React, { useState, useContext } from "react";
export const LoggedInContext = React.createContext(false);
export const useLoggedIn = () => {
return useContext(LoggedInContext);
};

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="has-navbar-fixed-top">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

View File

@ -1,6 +1,8 @@
html, body {
height: 100%;
background-color: #f7f7f7;
background-color: #ffffff;
// Generated with https://www.svgbackgrounds.com/
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='351' height='292.5' viewBox='0 0 1080 900'%3E%3Cg fill-opacity='0.04'%3E%3Cpolygon fill='%23444' points='90 150 0 300 180 300'/%3E%3Cpolygon points='90 150 180 0 0 0'/%3E%3Cpolygon fill='%23AAA' points='270 150 360 0 180 0'/%3E%3Cpolygon fill='%23DDD' points='450 150 360 300 540 300'/%3E%3Cpolygon fill='%23999' points='450 150 540 0 360 0'/%3E%3Cpolygon points='630 150 540 300 720 300'/%3E%3Cpolygon fill='%23DDD' points='630 150 720 0 540 0'/%3E%3Cpolygon fill='%23444' points='810 150 720 300 900 300'/%3E%3Cpolygon fill='%23FFF' points='810 150 900 0 720 0'/%3E%3Cpolygon fill='%23DDD' points='990 150 900 300 1080 300'/%3E%3Cpolygon fill='%23444' points='990 150 1080 0 900 0'/%3E%3Cpolygon fill='%23DDD' points='90 450 0 600 180 600'/%3E%3Cpolygon points='90 450 180 300 0 300'/%3E%3Cpolygon fill='%23666' points='270 450 180 600 360 600'/%3E%3Cpolygon fill='%23AAA' points='270 450 360 300 180 300'/%3E%3Cpolygon fill='%23DDD' points='450 450 360 600 540 600'/%3E%3Cpolygon fill='%23999' points='450 450 540 300 360 300'/%3E%3Cpolygon fill='%23999' points='630 450 540 600 720 600'/%3E%3Cpolygon fill='%23FFF' points='630 450 720 300 540 300'/%3E%3Cpolygon points='810 450 720 600 900 600'/%3E%3Cpolygon fill='%23DDD' points='810 450 900 300 720 300'/%3E%3Cpolygon fill='%23AAA' points='990 450 900 600 1080 600'/%3E%3Cpolygon fill='%23444' points='990 450 1080 300 900 300'/%3E%3Cpolygon fill='%23222' points='90 750 0 900 180 900'/%3E%3Cpolygon points='270 750 180 900 360 900'/%3E%3Cpolygon fill='%23DDD' points='270 750 360 600 180 600'/%3E%3Cpolygon points='450 750 540 600 360 600'/%3E%3Cpolygon points='630 750 540 900 720 900'/%3E%3Cpolygon fill='%23444' points='630 750 720 600 540 600'/%3E%3Cpolygon fill='%23AAA' points='810 750 720 900 900 900'/%3E%3Cpolygon fill='%23666' points='810 750 900 600 720 600'/%3E%3Cpolygon fill='%23999' points='990 750 900 900 1080 900'/%3E%3Cpolygon fill='%23999' points='180 0 90 150 270 150'/%3E%3Cpolygon fill='%23444' points='360 0 270 150 450 150'/%3E%3Cpolygon fill='%23FFF' points='540 0 450 150 630 150'/%3E%3Cpolygon points='900 0 810 150 990 150'/%3E%3Cpolygon fill='%23222' points='0 300 -90 450 90 450'/%3E%3Cpolygon fill='%23FFF' points='0 300 90 150 -90 150'/%3E%3Cpolygon fill='%23FFF' points='180 300 90 450 270 450'/%3E%3Cpolygon fill='%23666' points='180 300 270 150 90 150'/%3E%3Cpolygon fill='%23222' points='360 300 270 450 450 450'/%3E%3Cpolygon fill='%23FFF' points='360 300 450 150 270 150'/%3E%3Cpolygon fill='%23444' points='540 300 450 450 630 450'/%3E%3Cpolygon fill='%23222' points='540 300 630 150 450 150'/%3E%3Cpolygon fill='%23AAA' points='720 300 630 450 810 450'/%3E%3Cpolygon fill='%23666' points='720 300 810 150 630 150'/%3E%3Cpolygon fill='%23FFF' points='900 300 810 450 990 450'/%3E%3Cpolygon fill='%23999' points='900 300 990 150 810 150'/%3E%3Cpolygon points='0 600 -90 750 90 750'/%3E%3Cpolygon fill='%23666' points='0 600 90 450 -90 450'/%3E%3Cpolygon fill='%23AAA' points='180 600 90 750 270 750'/%3E%3Cpolygon fill='%23444' points='180 600 270 450 90 450'/%3E%3Cpolygon fill='%23444' points='360 600 270 750 450 750'/%3E%3Cpolygon fill='%23999' points='360 600 450 450 270 450'/%3E%3Cpolygon fill='%23666' points='540 600 630 450 450 450'/%3E%3Cpolygon fill='%23222' points='720 600 630 750 810 750'/%3E%3Cpolygon fill='%23FFF' points='900 600 810 750 990 750'/%3E%3Cpolygon fill='%23222' points='900 600 990 450 810 450'/%3E%3Cpolygon fill='%23DDD' points='0 900 90 750 -90 750'/%3E%3Cpolygon fill='%23444' points='180 900 270 750 90 750'/%3E%3Cpolygon fill='%23FFF' points='360 900 450 750 270 750'/%3E%3Cpolygon fill='%23AAA' points='540 900 630 750 450 750'/%3E%3Cpolygon fill='%23FFF' points='720 900 810 750 630 750'/%3E%3Cpolygon fill='%23222' points='900 900 990 750 810 750'/%3E%3Cpolygon fill='%23222' points='1080 300 990 450 1170 450'/%3E%3Cpolygon fill='%23FFF' points='1080 300 1170 150 990 150'/%3E%3Cpolygon points='1080 600 990 750 1170 750'/%3E%3Cpolygon fill='%23666' points='1080 600 1170 450 990 450'/%3E%3Cpolygon fill='%23DDD' points='1080 900 1170 750 990 750'/%3E%3C/g%3E%3C/svg%3E");
}
.is-fullheight {
@ -18,4 +20,8 @@ html, body {
#app {
display: flex;
flex-direction: column;
}
.panel {
background-color: #ffffff;
}

View File

@ -0,0 +1,35 @@
import { Workgroup } from "./workgroup";
export enum DecisionSupportFileStatus {
Draft = "draft",
Ready = "ready",
Voted = "voted",
Closed = "closed",
}
export interface DecisionSupportFileSection {
name: string
}
// aka Dossier d'aide à la décision
export interface DecisionSupportFile {
id: string
title: string
sections: {[name: string]: any}
status: DecisionSupportFileStatus
workgroup?: Workgroup,
createdAt: Date
votedAt?: Date
closedAt?: Date
}
export function newDecisionSupportFile(): DecisionSupportFile {
return {
id: '',
title: '',
sections: {},
status: DecisionSupportFileStatus.Draft,
workgroup: null,
createdAt: new Date(),
};
}

View File

@ -1,9 +1,18 @@
import { User } from "./user";
export interface Workgroup {
id: string
name: string
createdAt: Date
closedAt: Date
members: [User]
members: User[]
}
export function inWorkgroup(u: User, wg: Workgroup): boolean {
for (let m, i = 0; (m = wg.members[i]); i++) {
if(m.id === u.id) {
return true;
}
}
return false;
}

4
client/src/util/date.ts Normal file
View File

@ -0,0 +1,4 @@
export function asDate(d: string|Date): Date {
if (typeof d === 'string') return new Date(d);
return d;
}

View File

@ -7,6 +7,7 @@ import (
"github.com/wader/gormstore"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/logger"
@ -96,5 +97,7 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
oidc.WithScopes("email", "openid"),
))
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
return ctn, nil
}

View File

@ -80,6 +80,7 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
var initialModels = []interface{}{
&model.User{},
&model.Workgroup{},
&model.DecisionSupportFile{},
}
func m000initialSchema() orm.Migration {
@ -95,8 +96,8 @@ func m000initialSchema() orm.Migration {
return nil
},
func(ctx context.Context, tx *gorm.DB) error {
for _, m := range initialModels {
if err := tx.DropTableIfExists(m).Error; err != nil {
for i := len(initialModels) - 1; i >= 0; i-- {
if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
return errors.WithStack(err)
}
}

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.14
require (
forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032
github.com/99designs/gqlgen v0.11.3
github.com/antonmedv/expr v1.8.8
github.com/caarlos0/env/v6 v6.2.2
github.com/cortesi/modd v0.0.0-20200630120222-8983974e5450 // indirect
github.com/davecgh/go-spew v1.1.1

17
go.sum
View File

@ -20,6 +20,7 @@ github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2b
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
@ -44,6 +45,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2c
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/antonmedv/expr v1.8.8 h1:uVwIkIBNO2yn4vY2u2DQUqXTmv9jEEMCEcHa19G5weY=
github.com/antonmedv/expr v1.8.8/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY=
@ -71,6 +74,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -90,6 +94,8 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
@ -230,6 +236,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -250,6 +258,8 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@ -267,11 +277,14 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 h1:FLWDC+iIP9BWgYKvWKKtOUZux35LIQNAuIzp/63RQJU=
github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@ -283,6 +296,7 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@ -299,6 +313,7 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@ -410,6 +425,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -418,6 +434,7 @@ golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

71
internal/auth/auth.go Normal file
View File

@ -0,0 +1,71 @@
package auth
import (
"sync"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"github.com/antonmedv/expr"
"github.com/antonmedv/expr/vm"
"github.com/pkg/errors"
)
var (
ErrUnexpectedRuleResult = errors.New("unexpected rule result")
)
type Service struct {
rules []*vm.Program
mutex sync.RWMutex
}
func (s *Service) LoadRules(rawRules ...string) error {
rules := make([]*vm.Program, 0, len(rawRules))
for _, rr := range rawRules {
r, err := expr.Compile(rr)
if err != nil {
return errors.WithStack(err)
}
rules = append(rules, r)
}
s.mutex.Lock()
s.rules = rules
s.mutex.Unlock()
return nil
}
func (s *Service) Authorize(user *model.User) (bool, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
env := map[string]interface{}{
"user": user,
}
for _, r := range s.rules {
result, err := expr.Run(r, env)
if err != nil {
return false, errors.WithStack(err)
}
authorized, ok := result.(bool)
if !ok {
return false, errors.WithStack(ErrUnexpectedRuleResult)
}
if !authorized {
return false, nil
}
}
return true, nil
}
func NewService() *Service {
return &Service{
rules: make([]*vm.Program, 0),
}
}

20
internal/auth/provider.go Normal file
View File

@ -0,0 +1,20 @@
package auth
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(rules []string) service.Provider {
srv := NewService()
err := srv.LoadRules(rules...)
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, errors.WithStack(err)
}
return srv, nil
}
}

33
internal/auth/service.go Normal file
View File

@ -0,0 +1,33 @@
package auth
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "auth"
// From retrieves the auth service in the given container.
func From(container *service.Container) (*Service, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Service)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the auth service in the given container or panic otherwise.
func Must(container *service.Container) *Service {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

View File

@ -18,6 +18,7 @@ type Config struct {
HTTP HTTPConfig `yaml:"http"`
OIDC OIDCConfig `yaml:"oidc"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
}
// NewFromFile retrieves the configuration from the given file
@ -69,6 +70,10 @@ type DatabaseConfig struct {
DSN string `yaml:"dsn" env:"DATABASE_DSN"`
}
type AuthConfig struct {
Rules []string `yaml:"rules" env:"AUTH_RULES"`
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
@ -102,6 +107,11 @@ func NewDefault() *Config {
Database: DatabaseConfig{
DSN: "host=localhost database=daddy",
},
Auth: AuthConfig{
Rules: []string{
"user.Email endsWith 'cadoles.com'",
},
},
}
}

View File

@ -0,0 +1,59 @@
package graph
import (
"context"
"encoding/json"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
"gitlab.com/wpetit/goweb/middleware/container"
"forge.cadoles.com/Cadoles/daddy/internal/model"
errs "github.com/pkg/errors"
)
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
repo := model.NewDSFRepository(db)
dsf, err := repo.Create(ctx, changes)
if 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.Update(ctx, id, changes)
if err != nil {
return nil, errs.WithStack(err)
}
return dsf, nil
}
func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSupportFileFilter) ([]*model.DecisionSupportFile, error) {
ctn := container.Must(ctx)
db := orm.Must(ctn).DB()
repo := model.NewDSFRepository(db)
return repo.Search(ctx, filter)
}
func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) {
sections := make(map[string]interface{})
if err := json.Unmarshal(dsf.Sections.RawMessage, &sections); err != nil {
return nil, errs.WithStack(err)
}
return sections, nil
}

View File

@ -6,8 +6,21 @@ input WorkgroupChanges {
name: String
}
input DecisionSupportFileChanges {
title: String
sections: Map
status: String
workgroupId: ID
votedAt: Time
closedAt: Time
}
type Mutation {
createDecisionSupportFile(changes: DecisionSupportFileChanges): DecisionSupportFile!
updateDecisionSupportFile(id: ID!, changes: DecisionSupportFileChanges): DecisionSupportFile!
updateProfile(changes: ProfileChanges!): User!
joinWorkgroup(workgroupId: ID!): Workgroup!
leaveWorkgroup(workgroupId: ID!): Workgroup!
createWorkgroup(changes: WorkgroupChanges!): Workgroup!

View File

@ -10,6 +10,14 @@ import (
"forge.cadoles.com/Cadoles/daddy/internal/model"
)
func (r *mutationResolver) CreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
return handleCreateDecisionSupportFile(ctx, changes)
}
func (r *mutationResolver) UpdateDecisionSupportFile(ctx context.Context, id string, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
return handleUpdateDecisionSupportFile(ctx, id, changes)
}
func (r *mutationResolver) UpdateProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
return handleUpdateUserProfile(ctx, changes)
}

View File

@ -1,4 +1,5 @@
scalar Time
scalar Map
type User {
id: ID!
@ -21,7 +22,24 @@ input WorkgroupsFilter {
ids: [ID]
}
type DecisionSupportFile {
id: ID!
title: String
sections: Map
status: String
workgroup: Workgroup
createdAt: Time
updatedAt: Time
votedAt: Time
closedAt: Time
}
input DecisionSupportFileFilter {
ids: [ID]
}
type Query {
userProfile: User
workgroups(filter: WorkgroupsFilter): [Workgroup]!
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
}

View File

@ -11,6 +11,14 @@ import (
model1 "forge.cadoles.com/Cadoles/daddy/internal/model"
)
func (r *decisionSupportFileResolver) ID(ctx context.Context, obj *model1.DecisionSupportFile) (string, error) {
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 *queryResolver) UserProfile(ctx context.Context) (*model1.User, error) {
return handleUserProfile(ctx)
}
@ -19,6 +27,10 @@ func (r *queryResolver) Workgroups(ctx context.Context, filter *model1.Workgroup
return handleWorkgroups(ctx, filter)
}
func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1.DecisionSupportFileFilter) ([]*model1.DecisionSupportFile, error) {
return handleDecisionSupportFiles(ctx, filter)
}
func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
@ -27,6 +39,11 @@ func (r *workgroupResolver) ID(ctx context.Context, obj *model1.Workgroup) (stri
return strconv.FormatUint(uint64(obj.ID), 10), nil
}
// DecisionSupportFile returns generated.DecisionSupportFileResolver implementation.
func (r *Resolver) DecisionSupportFile() generated.DecisionSupportFileResolver {
return &decisionSupportFileResolver{r}
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
@ -36,6 +53,7 @@ func (r *Resolver) User() generated.UserResolver { return &userResolver{r} }
// Workgroup returns generated.WorkgroupResolver implementation.
func (r *Resolver) Workgroup() generated.WorkgroupResolver { return &workgroupResolver{r} }
type decisionSupportFileResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type workgroupResolver struct{ *Resolver }

19
internal/model/dsf.go Normal file
View File

@ -0,0 +1,19 @@
package model
import (
"time"
"github.com/jinzhu/gorm"
"github.com/jinzhu/gorm/dialects/postgres"
)
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"`
}

View File

@ -0,0 +1,111 @@
package model
import (
"context"
"encoding/json"
"errors"
"github.com/jinzhu/gorm"
"github.com/jinzhu/gorm/dialects/postgres"
errs "github.com/pkg/errors"
)
var (
ErrMissingWorkgroup = errs.New("missing workgroup")
ErrInvalidWorkgroup = errs.New("invalid workgroup")
ErrDecisionSupportFileDoesNotExist = errs.New("decision support file does not exist")
)
type DSFRepository struct {
db *gorm.DB
}
func (r *DSFRepository) Create(ctx context.Context, changes *DecisionSupportFileChanges) (*DecisionSupportFile, error) {
dsf := &DecisionSupportFile{}
if err := r.updateFromChanges(dsf, changes); err != nil {
return nil, errs.WithStack(err)
}
if err := r.db.Save(&dsf).Error; err != nil {
return nil, errs.WithStack(err)
}
return dsf, nil
}
func (r *DSFRepository) Update(ctx context.Context, id string, changes *DecisionSupportFileChanges) (*DecisionSupportFile, error) {
dsf := &DecisionSupportFile{}
if err := r.db.Find(dsf, "id = ?", id).Error; err != nil {
return nil, errs.WithStack(err)
}
if err := r.updateFromChanges(dsf, changes); err != nil {
return nil, errs.WithStack(err)
}
if err := r.db.Save(dsf).Error; err != nil {
return nil, errs.WithStack(err)
}
return dsf, nil
}
func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *DecisionSupportFileChanges) error {
if changes.WorkgroupID == nil {
return errs.WithStack(ErrMissingWorkgroup)
}
wg := &Workgroup{}
if err := r.db.Model(wg).First(wg, "id = ?", changes.WorkgroupID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.WithStack(ErrInvalidWorkgroup)
}
return errs.WithStack(err)
}
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}
}
if changes.Title != nil {
dsf.Title = *changes.Title
}
if changes.Status != nil {
dsf.Status = *changes.Status
}
return nil
}
func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) {
query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup")
if filter != nil {
if filter.Ids != nil {
query = query.Where("id in (?)", filter.Ids)
}
}
dsfs := make([]*DecisionSupportFile, 0)
if err := query.Find(&dsfs).Error; err != nil {
return nil, errs.WithStack(err)
}
return dsfs, nil
}
func NewDSFRepository(db *gorm.DB) *DSFRepository {
return &DSFRepository{db}
}

View File

@ -1,8 +1,11 @@
package route
import (
"fmt"
"net/http"
"forge.cadoles.com/Cadoles/daddy/internal/auth"
"forge.cadoles.com/Cadoles/daddy/internal/model"
"forge.cadoles.com/Cadoles/daddy/internal/orm"
@ -31,6 +34,7 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctn := container.Must(ctx)
conf := config.Must(ctn)
auth := auth.Must(ctn)
idToken, err := oidc.IDToken(w, r)
if err != nil {
@ -65,10 +69,26 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
db := orm.Must(ctn).DB()
repo := model.NewUserRepository(db)
if _, err := repo.CreateOrConnectUser(ctx, claims.Email); err != nil {
user, err := repo.CreateOrConnectUser(ctx, claims.Email)
if err != nil {
panic(errors.Wrap(err, "could not upsert user"))
}
authorized, err := auth.Authorize(user)
if err != nil {
panic(errors.WithStack(err))
}
if !authorized {
message := fmt.Sprintf(
"You are not authorized to access this application. Disconnect by navigating to %s.",
"http://"+r.Host+"/logout",
)
http.Error(w, message, http.StatusForbidden)
return
}
if err := session.SaveUserEmail(w, r, claims.Email); err != nil {
panic(errors.WithStack(err))
}