added chapter view, fixes, added ratings, added chapter updates/deletions

This commit is contained in:
lnn0q 2025-06-01 00:37:26 +03:00
parent 029b7e9353
commit 4801cf3a13
34 changed files with 1345 additions and 191 deletions

View file

@ -13,6 +13,9 @@ import { UserOneResolver } from './resolvers/userOneResolver';
import { LoginResolver } from './resolvers/auth/login';
import { LogoutResolver } from './resolvers/auth/logout';
import { SignUpResolver } from './resolvers/auth/signUp';
import { BookChapterCreateResolver } from './resolvers/bookChapterCreateResolver';
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
import { BookCreateResolver } from './resolvers/bookCreateResolver';
export const Query: QueryResolvers = {
@ -28,7 +31,10 @@ export const Mutation: MutationResolvers = {
signUp: SignUpResolver,
login: LoginResolver,
logout: LogoutResolver,
bookCreate: BookCreateResolver
bookCreate: BookCreateResolver,
bookChapterCreate: BookChapterCreateResolver,
bookChapterDelete: BookChapterDeleteResolver,
bookChapterUpdate: BookChapterUpdateResolver
};
export const Subscription = {};

View file

@ -0,0 +1,38 @@
import { countWords, ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { BookChapter, MutationBookChapterCreateArgs } from '@generated/types';
import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterCreateResolver(
_: any,
{ input }: MutationBookChapterCreateArgs,
{ req, res, identity }: IdentityContext
): Promise<BookChapter> {
ensureIdentity(identity);
if (input.title.length > 60) throw new Error('Title is too long.');
if (input.foreword != null && input.foreword.length > 500)
throw new Error('Foreword is too long.');
if (input.footnote != null && input.footnote.length > 500)
throw new Error('Footnote is too long.');
const chapterCount = await prisma.bookChapter.count({ where: { bookId: input.bookId } });
const model = await prisma.bookChapter.create({
data: {
id: uuid(),
bookId: input.bookId,
title: input.title,
foreword: input.foreword,
footnote: input.footnote,
content: input.content,
wordsTotal: countWords(input.content),
sequenceNumber: chapterCount + 1
}
});
await updateBookStats(input.bookId);
return toBookChapterSchema(model, { req, res, identity });
}

View file

@ -0,0 +1,25 @@
import { ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { Book, MutationBookChapterDeleteArgs } from '@generated/types';
import { toBookSchema } from './mappers/bookMapper';
export async function BookChapterDeleteResolver(
_: any,
{ id }: MutationBookChapterDeleteArgs,
{ req, res, identity }: IdentityContext
): Promise<Book> {
ensureIdentity(identity);
const model = await prisma.bookChapter.findUniqueOrThrow({
where: { id },
select: { book: { select: { id: true, authorId: true } } }
});
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
await prisma.bookChapter.delete({ where: { id } });
await updateBookStats(model.book.id);
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
return toBookSchema(book, { req, res, identity });
}

View file

@ -7,7 +7,7 @@ import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterManyResolver(
_: any,
{ input }: QueryBookChapterManyArgs,
{ identity }: IdentityContext
{ req, res, identity }: IdentityContext
): Promise<BookChapterManyOutput> {
const { bookId, filter } = input;
const terms = filter.terms?.toLowerCase();
@ -19,11 +19,14 @@ export async function BookChapterManyResolver(
const items = await prisma.bookChapter.findMany({
where: bookChapterWhere,
skip: filter.offset ?? 0,
take: filter.size ?? 10
take: filter.size ?? 10,
orderBy: { sequenceNumber: 'asc' }
});
return {
items: await Promise.all(items.map(value => toBookChapterSchema(value))),
items: await Promise.all(
items.map(value => toBookChapterSchema(value, { req, res, identity }))
),
total: items.length
};
}

View file

@ -6,12 +6,12 @@ import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterOneResolver(
_: any,
{ id }: QueryBookChapterOneArgs,
{ identity }: IdentityContext
{ req, res, identity }: IdentityContext
): Promise<BookChapter> {
const model = await prisma.bookChapter.findUniqueOrThrow({
where: {
id
}
});
return toBookChapterSchema(model);
return toBookChapterSchema(model, { req, res, identity });
}

View file

@ -0,0 +1,34 @@
import { countWords, ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { updateBookStats } from '@app/server/service/book';
import { BookChapter, MutationBookChapterUpdateArgs } from '@generated/types';
import { toBookChapterSchema } from './mappers/bookChapterMapper';
export async function BookChapterUpdateResolver(
_: any,
{ input }: MutationBookChapterUpdateArgs,
{ req, res, identity }: IdentityContext
): Promise<BookChapter> {
ensureIdentity(identity);
if (input.title.length > 60) throw new Error('Title is too long.');
if (input.foreword != null && input.foreword.length > 500)
throw new Error('Foreword is too long.');
if (input.footnote != null && input.footnote.length > 500)
throw new Error('Footnote is too long.');
const model = await prisma.bookChapter.update({
where: { id: input.id },
data: {
title: input.title,
foreword: input.foreword,
footnote: input.footnote,
content: input.content,
wordsTotal: countWords(input.content)
}
});
await updateBookStats(model.bookId);
return toBookChapterSchema(model, { req, res, identity });
}

View file

@ -7,11 +7,10 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookCreateResolver(
_: any,
{ input }: MutationBookCreateArgs,
{ identity }: IdentityContext
{ req, res, identity }: IdentityContext
): Promise<Book> {
ensureIdentity(identity);
console.log(input.title.length);
if (input.title.length > 60) throw new Error('Title is too long.');
if (input.description.length > 900) throw new Error('Description is too long.');
@ -23,5 +22,5 @@ export async function BookCreateResolver(
authorId: identity.id
}
});
return toBookSchema(model);
return toBookSchema(model, { req, res, identity });
}

View file

@ -7,7 +7,7 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookManyResolver(
_: any,
{ input }: QueryBookManyArgs,
{ identity }: IdentityContext
{ req, res, identity }: IdentityContext
): Promise<BookManyOutput> {
const { filter } = input;
const terms = filter.terms?.toLowerCase();
@ -20,11 +20,14 @@ export async function BookManyResolver(
const items = await prisma.book.findMany({
where: bookWhere,
skip: filter.offset ?? 0,
take: filter.size ?? 10
take: filter.size ?? 10,
orderBy: {
createdAt: 'desc'
}
});
return {
items: await Promise.all(items.map(value => toBookSchema(value))),
items: await Promise.all(items.map(value => toBookSchema(value, { req, res, identity }))),
total: await prisma.book.count({ where: bookWhere })
};
}

View file

@ -6,12 +6,12 @@ import { toBookSchema } from './mappers/bookMapper';
export async function BookOneResolver(
_: any,
{ id }: QueryBookOneArgs,
{ identity }: IdentityContext
{ req, res, identity }: IdentityContext
): Promise<Book> {
const model = await prisma.book.findUniqueOrThrow({
where: {
id
}
});
return toBookSchema(model);
return toBookSchema(model, { req, res, identity });
}

View file

@ -1,9 +1,15 @@
import { call } from '@app/common';
import { ensureRatingType } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import { BookChapter as BookChapterSchema } from '@generated/types';
import { BookChapter as BookChapterSchema, RatingType } from '@generated/types';
import { BookChapter } from '@prisma/client';
import { toBookSchema } from './bookMapper';
export async function toBookChapterSchema(model: BookChapter): Promise<BookChapterSchema> {
export async function toBookChapterSchema(
model: BookChapter,
{ req, res, identity }: IdentityContext
): Promise<BookChapterSchema> {
return {
id: model.id,
title: model.title,
@ -11,7 +17,44 @@ export async function toBookChapterSchema(model: BookChapter): Promise<BookChapt
updatedAt: model.createdAt.toISOString(),
foreword: model.foreword,
footnote: model.footnote,
content: model.content,
sequenceNumber: model.sequenceNumber,
previousChapterId: await call(async () => {
const isFirst = model.sequenceNumber === 1;
if (isFirst) return null;
const previousChapter = await prisma.bookChapter.findUniqueOrThrow({
where: {
bookId_sequenceNumber: {
bookId: model.bookId,
sequenceNumber: model.sequenceNumber - 1
}
},
select: {
id: true
}
});
return previousChapter.id;
}),
nextChapterId: await call(async () => {
const book = await prisma.book.findUniqueOrThrow({
where: { id: model.bookId },
select: { chaptersTotal: true }
});
const isLast = model.sequenceNumber === book.chaptersTotal;
if (isLast) return null;
const nextChapter = await prisma.bookChapter.findUniqueOrThrow({
where: {
bookId_sequenceNumber: {
bookId: model.bookId,
sequenceNumber: model.sequenceNumber + 1
}
},
select: { id: true }
});
return nextChapter.id;
}),
reviewsTotal: await call(async () => {
const reviewsCount = await prisma.review.count({
where: {
@ -19,6 +62,28 @@ export async function toBookChapterSchema(model: BookChapter): Promise<BookChapt
}
});
return reviewsCount;
}),
book: await call(async () => {
const book = await prisma.book.findUniqueOrThrow({
where: {
id: model.bookId
}
});
return toBookSchema(book, { req, res, identity });
}),
personalRating: await call(async () => {
if (identity == null) return RatingType.None;
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: model.id
}
}
});
if (rating == null) return RatingType.None;
ensureRatingType(rating.type);
return rating.type;
})
};
}

View file

@ -1,14 +1,20 @@
import { assertUnreachable } from '@app/client/helpers';
import { call } from '@app/common';
import { ensureRatingType } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma } from '@app/server/prisma/client';
import {
Book as BookSchema,
RatingType,
WorkingProgressStatus as WorkingProgressStatusSchema
} from '@generated/types';
import { Book, WorkingProgressStatus } from '@prisma/client';
import { toUserSchema } from './userMapper';
export async function toBookSchema(model: Book): Promise<BookSchema> {
export async function toBookSchema(
model: Book,
{ identity }: IdentityContext
): Promise<BookSchema> {
return {
id: model.id,
title: model.title,
@ -48,6 +54,20 @@ export async function toBookSchema(model: Book): Promise<BookSchema> {
}
});
return reviewsCount;
}),
personalRating: await call(async () => {
if (identity == null) return RatingType.None;
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: model.id
}
}
});
if (rating == null) return RatingType.None;
ensureRatingType(rating.type);
return rating.type;
})
};
}

View file

@ -0,0 +1,102 @@
import { ensureIdentity } from '@app/server/helpers';
import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client';
import { MutationRatingSetArgs, RatingType } from '@generated/types';
export async function BookCreateResolver(
_: any,
{ input }: MutationRatingSetArgs,
{ identity }: IdentityContext
): Promise<void> {
ensureIdentity(identity);
if (input.bookId != null) {
const rating = await prisma.rating.findUnique({
where: {
userId_bookId: {
userId: identity.id,
bookId: input.bookId
}
}
});
if (rating != null && input.ratingType !== RatingType.None) {
await prisma.rating.update({
where: { id: rating.id },
data: {
type: input.ratingType
}
});
return;
}
if (input.ratingType === RatingType.None) {
await prisma.rating.delete({
where: {
userId_bookId: {
userId: identity.id,
bookId: input.bookId
}
}
});
return;
}
await prisma.rating.create({
data: {
id: uuid(),
userId: identity.id,
bookId: input.bookId,
type: input.ratingType
}
});
return;
}
if (input.bookChapterId != null) {
const rating = await prisma.rating.findUnique({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: input.bookChapterId
}
}
});
if (rating != null && input.ratingType !== RatingType.None) {
await prisma.rating.update({
where: { id: rating.id },
data: {
type: input.ratingType
}
});
return;
}
if (input.ratingType === RatingType.None) {
await prisma.rating.delete({
where: {
userId_bookChapterId: {
userId: identity.id,
bookChapterId: input.bookChapterId
}
}
});
return;
}
await prisma.rating.create({
data: {
id: uuid(),
userId: identity.id,
bookChapterId: input.bookChapterId,
type: input.ratingType
}
});
return;
}
throw new Error('Invalid Rating id input.');
}

View file

@ -1,4 +1,5 @@
scalar Void
scalar JSON
type Query {
identity: Identity!
@ -15,6 +16,13 @@ type Mutation {
login(input: LoginInput!): Session!
logout: Void
bookCreate(input: BookCreateInput!): Book!
bookChapterCreate(input: BookChapterCreateInput!): BookChapter!
bookChapterUpdate(input: BookChapterUpdateInput!): BookChapter!
bookChapterDelete(id: ID!): Book!
reviewCreate(input: ReviewCreateInput!): Review!
reviewUpdate(input: ReviewUpdateInput!): Review!
reviewDelete(id: ID!): Void
ratingSet(input: RatingSetInput!): Void
}
input SignUpInput {
@ -43,6 +51,7 @@ type Identity {
type User {
id: ID!
username: String!
description: String
createdAt: String!
updatedAt: String!
}
@ -58,13 +67,6 @@ input BookCreateInput {
description: String!
}
input BookChapterCreateInput {
title: String!
content: String!
foreword: String
footnote: String
}
input BookManyInput {
filter: PaginationFilterInput!
}
@ -92,6 +94,7 @@ type Book {
chaptersTotal: Int!
ratingTotal: Int!
reviewsTotal: Int!
personalRating: RatingType!
}
type BookChapter {
@ -99,10 +102,31 @@ type BookChapter {
title: String!
createdAt: String!
updatedAt: String!
foreword: String!
footnote: String!
foreword: String
footnote: String
content: JSON!
sequenceNumber: Int!
reviewsTotal: Int!
previousChapterId: String
nextChapterId: String
book: Book!
personalRating: RatingType!
}
input BookChapterCreateInput {
bookId: ID!
title: String!
foreword: String
footnote: String
content: JSON!
}
input BookChapterUpdateInput {
id: ID!
title: String!
foreword: String
footnote: String
content: JSON!
}
input BookChapterManyInput {
@ -122,6 +146,17 @@ type Review {
ratingTotal: Int!
}
input ReviewCreateInput {
bookId: ID
chapterId: ID
text: String!
}
input ReviewUpdateInput {
id: ID!
text: String!
}
input ReviewManyInput {
bookId: String
bookChapterId: String
@ -132,3 +167,15 @@ type ReviewManyOutput {
items: [Review!]!
total: Int!
}
input RatingSetInput {
bookId: String
bookChapterId: String
ratingType: RatingType!
}
enum RatingType {
up
down
none
}