#11 backend: nodeId -> id and id -> rowId

To be more in sync with typical graphql servers.
This commit is contained in:
Christoph Lienhard 2020-12-29 11:35:34 +01:00
parent 96e78eb7f5
commit dd2f414f00
Signed by: christoph.lienhard
GPG key ID: 6B98870DDC270884
15 changed files with 205 additions and 124 deletions

View file

@ -1,12 +1,12 @@
-- create table for users
create table candymat_data.person
(
id serial primary key,
row_id serial primary key,
first_name character varying(200) check (first_name <> ''),
last_name character varying(200) check (last_name <> ''),
about character varying(2000),
created_at timestamp default now(),
role candymat_data.role not null default 'candymat_person'
created_at timestamp default now(),
role candymat_data.role not null default 'candymat_person'
);
grant select, update, delete on table candymat_data.person to candymat_person;
-- the following is only necessary as long as anonymous should be able to view candidates and editors
@ -15,16 +15,16 @@ grant select on table candymat_data.person to candymat_anonymous;
-- create table for accounts
create table candymat_data_privat.person_account
(
person_id integer primary key references candymat_data.person (id) on delete cascade,
person_row_id integer primary key references candymat_data.person (row_id) on delete cascade,
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
password_hash character varying(256) not null
);
alter table candymat_data.person
enable row level security;
create policy update_person on candymat_data.person for update to candymat_person
with check (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
with check (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
create policy delete_person on candymat_data.person for delete to candymat_person
using (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
using (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
-- The following enables viewing candidates and editors information for every person.
-- This may be changed to only enable registered (and verified) persons.
create policy select_person_public

View file

@ -1,7 +1,7 @@
-- create table for categories
create table candymat_data.category
(
id serial primary key,
row_id serial primary key,
title character varying(300) UNIQUE NOT NULL,
description character varying(5000)
);
@ -9,31 +9,31 @@ grant select on table candymat_data.category to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
grant select on table candymat_data.category to candymat_anonymous;
grant insert, update, delete on table candymat_data.category to candymat_editor;
grant usage on sequence candymat_data.category_id_seq to candymat_editor;
grant usage on sequence candymat_data.category_row_id_seq to candymat_editor;
-- create table for questions
create table candymat_data.question
(
id serial primary key,
category_id integer REFERENCES candymat_data.category (id) ON UPDATE CASCADE ON DELETE SET NULL,
text character varying(3000) NOT NULL,
description character varying(5000)
row_id serial primary key,
category_row_id integer REFERENCES candymat_data.category (row_id) ON UPDATE CASCADE ON DELETE SET NULL,
text character varying(3000) NOT NULL,
description character varying(5000)
);
grant select on table candymat_data.question to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
grant select on table candymat_data.question to candymat_anonymous;
grant insert, update, delete on table candymat_data.question to candymat_editor;
grant usage on sequence candymat_data.question_id_seq to candymat_editor;
grant usage on sequence candymat_data.question_row_id_seq to candymat_editor;
-- create table for answers
create table candymat_data.answer
(
question_id integer REFERENCES candymat_data.question (id) ON UPDATE CASCADE ON DELETE CASCADE,
person_id integer REFERENCES candymat_data.person (id) ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL,
text character varying(5000),
created_at timestamp default now(),
primary key (question_id, person_id)
question_row_id integer REFERENCES candymat_data.question (row_id) ON UPDATE CASCADE ON DELETE CASCADE,
person_row_id integer REFERENCES candymat_data.person (row_id) ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL,
text character varying(5000),
created_at timestamp default now(),
primary key (question_row_id, person_row_id)
);
grant select on table candymat_data.answer to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
@ -43,7 +43,7 @@ grant insert, update, delete on table candymat_data.answer to candymat_candidate
alter table candymat_data.answer
enable row level security;
create policy change_answer on candymat_data.answer to candymat_candidate
using (person_id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
using (person_row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
create policy select_answer
on candymat_data.answer
for select

View file

@ -1,16 +1,19 @@
create extension if not exists "pgcrypto";
-- Define JWT claim structure
create type candymat_data.jwt_token as (
role text,
person_id integer,
exp bigint
create type candymat_data.jwt_token as
(
role text,
person_row_id integer,
exp bigint
);
create function candymat_data.current_person() returns candymat_data.person as $$
select *
from candymat_data.person
where id = nullif(current_setting('jwt.claims.person_id', true), '')::integer
create function candymat_data.current_person(
) returns candymat_data.person as
$$
select *
from candymat_data.person
where row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer
$$ language sql stable;
grant execute on function candymat_data.current_person() to candymat_person;
@ -37,8 +40,8 @@ begin
values ($1, $2)
returning * into person;
insert into candymat_data_privat.person_account (person_id, email, password_hash)
values (person.id, $3, crypt($4, gen_salt('bf')));
insert into candymat_data_privat.person_account (person_row_id, email, password_hash)
values (person.row_id, $3, crypt($4, gen_salt('bf')));
return person;
end ;
@ -48,9 +51,10 @@ $$ language plpgsql strict
grant execute on function candymat_data.register_person(text, text, text, text) to candymat_anonymous;
create function candymat_data.authenticate(
email text,
password text
) returns candymat_data.jwt_token as $$
email text,
password text
) returns candymat_data.jwt_token as
$$
declare
account candymat_data_privat.person_account;
declare person candymat_data.person;
@ -63,30 +67,39 @@ begin
select p.*
into person
from candymat_data.person as p
where p.id = account.person_id;
where p.row_id = account.person_row_id;
if account.password_hash = crypt(password, account.password_hash) then
return (person.role, account.person_id,
return (person.role, account.person_row_id,
extract(epoch from (now() + interval '2 days')))::candymat_data.jwt_token;
else
return null;
end if;
end;
$$ language plpgsql strict security definer;
$$ language plpgsql strict
security definer;
grant execute on function candymat_data.authenticate(text, text) to candymat_anonymous, candymat_person;
create function candymat_data.change_role(
person_id integer,
new_role candymat_data.role
) returns table(first_name text, last_name text, role candymat_data.role) as $$
person_row_id integer,
new_role candymat_data.role
)
returns table
(
first_name text,
last_name text,
role candymat_data.role
)
as
$$
begin
update candymat_data.person
set role = new_role
where candymat_data.person.id = $1;
where candymat_data.person.row_id = $1;
return query select candymat_data.person.first_name::text, candymat_data.person.last_name::text, new_role
from candymat_data.person
where person.id = person_id;
where person.row_id = person_row_id;
end;
$$ language plpgsql;
grant execute on function candymat_data.change_role(integer, candymat_data.role) to candymat_editor;

View file

@ -3,7 +3,7 @@ insert into candymat_data.category (title, description) values
insert into candymat_data.category (title, description) values
('Sonstiges', '');
insert into candymat_data.question (category_id, text, description) values
insert into candymat_data.question (category_row_id, text, description) values
(1, 'Was sagen Sie zur 10H Regel?', 'In Bayern dürfen Windräder nur ...');
insert into candymat_data.question (category_id, text, description) values
insert into candymat_data.question (category_row_id, text, description) values
(2, 'Umgehungsstraße XY?', 'Zur Entlastung der Hauptstraße ...');

View file

@ -1,9 +1,9 @@
insert into candymat_data.answer (question_id, person_id, position, text) values
(1, 2, 2, 'bin dagegen');
insert into candymat_data.answer (question_id, person_id, position, text) values
(2, 2, 0, 'bin dafür');
insert into candymat_data.answer (question_row_id, person_row_id, position, text)
values (1, 2, 2, 'bin dagegen');
insert into candymat_data.answer (question_row_id, person_row_id, position, text)
values (2, 2, 0, 'bin dafür');
insert into candymat_data.answer (question_id, person_id, position, text) values
(1, 3, 1, 'mir egal');
insert into candymat_data.answer (question_id, person_id, position, text) values
(2, 3, 3, 'keine lust mehr');
insert into candymat_data.answer (question_row_id, person_row_id, position, text)
values (1, 3, 1, 'mir egal');
insert into candymat_data.answer (question_row_id, person_row_id, position, text)
values (2, 3, 3, 'keine lust mehr');

View file

@ -59,7 +59,8 @@ services:
"--jwt-secret", $JWT_SECRET,
"--watch",
"--retry-on-init-fail",
"--enhance-graphiql"
"--enhance-graphiql",
"--classic-ids",
]
networks:
- frontend

View file

@ -1,4 +1,4 @@
import {ApolloClient, createHttpLink, defaultDataIdFromObject, InMemoryCache} from "@apollo/client";
import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client";
import {setContext} from "@apollo/client/link/context";
@ -17,12 +17,6 @@ const authLink = setContext((_, { headers }) => {
});
export const client = new ApolloClient({
cache: new InMemoryCache({
dataIdFromObject(responseObject) {
return responseObject.nodeId
? responseObject.nodeId as string
: defaultDataIdFromObject(responseObject);
}
}),
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
});

View file

@ -1,16 +1,38 @@
import {gql} from "@apollo/client";
import {GetQuestionResponse} from "../queries/question";
import {BasicQuestionFragment, BasicQuestionResponse} from "../queries/question";
export const EDIT_QUESTION = gql`
mutation UpdateQuestion($nodeId: ID!, $text: String, $description: String, $categoryId: Int) {
updateQuestion(input: {nodeId: $nodeId, questionPatch: {categoryId: $categoryId, description: $description, text: $text}}) {
mutation UpdateQuestion($id: ID!, $text: String, $description: String, $categoryRowId: Int) {
updateQuestion(input: {id: $id, questionPatch: {categoryRowId: $categoryRowId, description: $description, text: $text}}) {
question {
nodeId
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
export interface EditQuestionResponse {
updateQuestion?: BasicQuestionResponse
}
export interface EditQuestionVariables {
id: string,
text?: string,
description?: string,
categoryRowId?: number | null,
}
export const ADD_QUESTION = gql`
mutation AddQuestion($text: String!, $description: String, $categoryRowId: Int) {
createQuestion(input: {question: {text: $text, categoryRowId: $categoryRowId, description: $description}}) {
question {
id
text
description
categoryByCategoryId {
nodeId
categoryByCategoryRowId {
id
rowId
title
}
}
@ -18,13 +40,39 @@ export const EDIT_QUESTION = gql`
}
`
export interface EditQuestionVariables {
nodeId: string,
text?: string,
description?: string,
categoryId?: number | null,
export interface AddQuestionResponse {
createQuestion?: BasicQuestionResponse
}
export interface EditQuestionResponse {
updateQuestion?: GetQuestionResponse
export interface AddQuestionVariables {
text: string,
description?: string,
categoryRowId?: number | null
}
export const DELETE_QUESTION = gql`
mutation AddQuestion($text: String!, $description: String, $categoryRowId: Int) {
createQuestion(input: {question: {text: $text, categoryRowId: $categoryRowId, description: $description}}) {
question {
id
text
description
categoryByCategoryRowId {
id
rowId
title
}
}
}
}
`
export interface DeleteQuestionResponse {
deleteQuestion?: BasicQuestionResponse
}
export interface DeleteQuestionVariables {
id: string,
}

View file

@ -4,7 +4,7 @@ export const SIGN_UP = gql`
mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) {
registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) {
person {
nodeId
id
}
}
}
@ -20,7 +20,7 @@ export interface SignUpVariables {
export interface SignUpResponse {
registerPerson: {
person: {
nodeId: string
id: string
}
}
}

View file

@ -1,27 +1,35 @@
import {gql} from "@apollo/client";
export const BasicCategoryFragment = gql`
fragment BasicCategory on Category {
id
rowId
title
description
}
`
export interface BasicCategoryResponse {
id: string,
rowId: number,
title: string,
description?: string,
}
export const GET_ALL_CATEGORIES = gql`
query AllCategories {
allCategories {
nodes {
nodeId
id
title
description
...BasicCategory
}
}
}
${BasicCategoryFragment}
`
export interface GetAllCategoriesResponse {
allCategories: {
nodes: Array<GetCategoryResponse>
nodes: Array<BasicCategoryResponse>
}
}
export interface GetCategoryResponse {
nodeId: string,
id: number,
title: string,
description?: string,
}

View file

@ -1,36 +1,53 @@
import {gql} from "@apollo/client";
const QuestionCategoryFragment = gql`
fragment QuestionCategoryFragment on Category {
id
rowId
title
}
`
interface GetQuestionsCategoryResponse {
id: string,
rowId: number,
title: string,
}
export const BasicQuestionFragment = gql`
fragment BasicQuestionFragment on Question {
id
text
description
categoryByCategoryRowId {
...QuestionCategoryFragment
}
}
${QuestionCategoryFragment}
`
export interface BasicQuestionResponse {
id: string,
text: string,
description?: string,
categoryByCategoryRowId?: GetQuestionsCategoryResponse
}
export const GET_ALL_QUESTIONS = gql`
query AllQuestions {
allQuestions {
nodes {
nodeId
text
description
categoryByCategoryId {
nodeId
title
}
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
export interface GetAllQuestionsResponse {
allQuestions: {
nodes: Array<GetQuestionResponse>
nodes: Array<BasicQuestionResponse>
}
}
export interface GetQuestionResponse {
nodeId: string,
text: string,
description?: string,
categoryByCategoryId?: GetQuestionsCategoryResponse
}
interface GetQuestionsCategoryResponse {
nodeId: string,
id: number,
title: string,
}

View file

@ -4,7 +4,7 @@ import {makeStyles} from "@material-ui/core/styles";
import {useQuery} from "@apollo/client";
import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse, GetCategoryResponse} from "../backend/queries/category";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse, BasicCategoryResponse} from "../backend/queries/category";
import ChangeCategoryDialog, {ChangeCategoryDialogContent} from "./ChangeCategoryDialog";
const useStyles = makeStyles((theme) => ({
@ -38,12 +38,12 @@ export default function CategoryList() {
setDialogOpen(true);
}
const handleEditButtonClick = (category: GetCategoryResponse) => {
const handleEditButtonClick = (category: BasicCategoryResponse) => {
setDialogTitle("Kategorie bearbeiten");
setDialogConfirmButtonText("Speichern")
if (dialogContent.id !== category.nodeId) {
if (dialogContent.id !== category.id) {
setDialogContent({
id: category.nodeId,
id: category.id,
title: category.title,
details: category.description,
})
@ -59,7 +59,7 @@ export default function CategoryList() {
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Kategorien</Typography>
{data?.allCategories.nodes.map(category => <AccordionWithEdit
key={category.nodeId}
key={category.id}
title={category.title}
description={category.description}
onEditButtonClick={() => handleEditButtonClick(category)}

View file

@ -1,11 +1,11 @@
import React, {ChangeEvent} from 'react';
import {FormControl, InputLabel, MenuItem, Select} from "@material-ui/core";
import {GetCategoryResponse} from "../backend/queries/category";
import {BasicCategoryResponse} from "../backend/queries/category";
interface CategorySelectionMenuProps {
selectedCategoryId: number | null
categories?: Array<GetCategoryResponse>,
categories?: Array<BasicCategoryResponse>,
handleCategoryChange(categoryId: number | null): void
}
@ -27,7 +27,7 @@ export default function CategorySelectionMenu(props: CategorySelectionMenuProps)
<MenuItem value={undefined}>
<em>None</em>
</MenuItem>
{props.categories?.map(category => <MenuItem key={category.nodeId} value={category.id}>
{props.categories?.map(category => <MenuItem key={category.id} value={category.rowId}>
{category.title}
</MenuItem>)}
</Select>

View file

@ -2,7 +2,7 @@ import React from 'react';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {GetCategoryResponse} from "../backend/queries/category";
import {BasicCategoryResponse} from "../backend/queries/category";
import CategorySelectionMenu from "./CategorySelectionMenu";
import {DialogActionBar} from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails";
@ -21,7 +21,7 @@ interface ChangeQuestionDialogProps {
open: boolean,
content: ChangeQuestionDialogContent,
loading: boolean,
categories?: Array<GetCategoryResponse>,
categories?: Array<BasicCategoryResponse>,
handleContentChange(content: ChangeQuestionDialogContent): void

View file

@ -4,7 +4,7 @@ import {makeStyles} from "@material-ui/core/styles";
import {useMutation, useQuery} from "@apollo/client";
import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit";
import {GET_ALL_QUESTIONS, GetAllQuestionsResponse, GetQuestionResponse} from "../backend/queries/question";
import {GET_ALL_QUESTIONS, GetAllQuestionsResponse, BasicQuestionResponse} from "../backend/queries/question";
import ChangeQuestionDialog, {ChangeQuestionDialogContent} from "./ChangeQuestionDialog";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category";
import {EDIT_QUESTION, EditQuestionResponse, EditQuestionVariables} from "../backend/mutations/question";
@ -51,15 +51,15 @@ export default function QuestionList() {
setDialogOpen(true);
}
const handleEditButtonClick = (question: GetQuestionResponse) => {
const handleEditButtonClick = (question: BasicQuestionResponse) => {
setDialogTitle("Frage bearbeiten");
setDialogConfirmButtonText("Speichern")
if (dialogContent.id !== question.nodeId) {
if (dialogContent.id !== question.id) {
setDialogContent({
id: question.nodeId,
id: question.id,
title: question.text,
details: question.description,
categoryId: question.categoryByCategoryId ? question.categoryByCategoryId.id : null,
categoryId: question.categoryByCategoryRowId ? question.categoryByCategoryRowId.rowId : null,
})
}
setDialogOpen(true);
@ -73,10 +73,10 @@ export default function QuestionList() {
if (dialogContent.id !== "") {
const response = await editQuestion({
variables: {
nodeId: dialogContent.id,
id: dialogContent.id,
text: dialogContent.title,
description: dialogContent.details,
categoryId: dialogContent.categoryId
categoryRowId: dialogContent.categoryId
}
})
if (response.data?.updateQuestion) {
@ -97,9 +97,9 @@ export default function QuestionList() {
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography>
{questions?.map(question => {
return <AccordionWithEdit
key={question.nodeId}
key={question.id}
title={question.text}
subTitle={question.categoryByCategoryId?.title}
subTitle={question.categoryByCategoryRowId?.title}
description={question.description}
onEditButtonClick={() => handleEditButtonClick(question)}
/>;