Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,10 @@ jobs:

- run: yarn install --frozen-lockfile
- run: yarn run lint
- run: yarn run type-check
- run: yarn test
- run: yarn build
env:
# Build only needs schema-valid env; secrets are not exercised.
MONGODB_URI: mongodb://localhost:27017/articlify-ci
BETTER_AUTH_SECRET: ci-build-only-not-used-at-runtime
Comment on lines +27 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide storage envs for build validation step

The new yarn build step is configured with only MONGODB_URI and BETTER_AUTH_SECRET, but build-time imports reach getServerConfig() (via server/context.tsfeatures/auth/auth.ts), and in production mode the config transform now defaults STORAGE_PROVIDER to minio and throws unless the full S3_* set is present (src/shared/config/env/server.ts). In this workflow context, that means the job can fail before the app compiles, so the added build gate is effectively broken unless storage env vars (or provider override) are supplied.

Useful? React with 👍 / 👎.

8 changes: 5 additions & 3 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import { Alert, AlertDescription, AlertTitle } from '~/shared/ui/alert';
import '~/app/styles/globals.css';

// next-intl context is unavailable above the [locale] segment, so we render
// EN + RU side-by-side rather than picking a single language for the user.
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
reportError({

Check failure on line 14 in app/global-error.tsx

View workflow job for this annotation

GitHub Actions / test

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
message: error.message || 'A critical error occurred.',
digest: error.digest,
stack: error.stack,
Expand All @@ -22,12 +24,12 @@
<div className="container mx-auto flex min-h-screen items-center justify-center px-4">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Something went wrong</AlertTitle>
<AlertTitle>Something went wrong / Что-то пошло не так</AlertTitle>
<AlertDescription className="mt-2">
{error.message || 'A critical error occurred.'}
{error.message || 'A critical error occurred. / Произошла критическая ошибка.'}
</AlertDescription>
<Button onClick={reset} className="mt-4" variant="outline">
Try again
Try again / Попробовать снова
</Button>
</Alert>
</div>
Expand Down
10 changes: 7 additions & 3 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import Link from 'next/link';
import { Button } from '~/shared/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card';

// This file only renders when the request is outside the [locale] segment
// (e.g. a static asset miss). The localized variant in app/[locale]/not-found.tsx
// handles in-app 404s. Bilingual fallback to avoid an English-only leak.
export default function RootNotFound() {
return (
<div className="container mx-auto flex min-h-screen items-center justify-center px-4">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="text-4xl">404</CardTitle>
<CardDescription className="text-lg">Page Not Found</CardDescription>
<CardDescription className="text-lg">Page Not Found / Страница не найдена</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
The page you are looking for doesn&apos;t exist or has been moved.
The page you are looking for doesn&apos;t exist or has been moved. / Запрашиваемая страница не
существует или была перемещена.
</p>
<Button asChild>
<Link href="/">Go Home</Link>
<Link href="/">Go Home / На главную</Link>
</Button>
</CardContent>
</Card>
Expand Down
22 changes: 22 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import nextVitals from 'eslint-config-next/core-web-vitals';
import eslintConfigPrettier from 'eslint-config-prettier';
import prettier from 'eslint-plugin-prettier';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';

const eslintConfig = defineConfig([
...nextVitals,
Expand All @@ -20,6 +22,26 @@
'react/display-name': 'off',
},
},
// Async-safety: catch floating promises and misused async handlers.
// Type-aware linting is required for these rules.
{
files: ['src/**/*.{ts,tsx}', 'server/**/*.ts', 'app/**/*.{ts,tsx}'],
plugins: { '@typescript-eslint': tseslint },
languageOptions: {
parser: tsparser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': [

Check failure on line 39 in eslint.config.mjs

View workflow job for this annotation

GitHub Actions / test

Replace `⏎················'error',⏎················{·checksVoidReturn:·{·attributes:·false·}·},⏎············` with `'error',·{·checksVoidReturn:·{·attributes:·false·}·}`
'error',
{ checksVoidReturn: { attributes: false } },
],
},
},
// FSD layer boundaries: shared cannot import from higher layers
{
files: ['src/shared/**/*.ts', 'src/shared/**/*.tsx'],
Expand Down
2 changes: 1 addition & 1 deletion server/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const router = t.router;
export const publicProcedure = t.procedure.use(loggingMiddleware);

// Protected procedure - requires authentication
export const protectedProcedure = t.procedure.use(async (opts) => {
export const protectedProcedure = t.procedure.use(loggingMiddleware).use(async (opts) => {
const { ctx } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
Expand Down
70 changes: 44 additions & 26 deletions src/entities/article/api/__tests__/article.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,43 +85,57 @@ describe('ArticleService', () => {
);
});

it('update allows when user is author', async () => {
const article = { ...baseArticle(), author: 'user1' };
it('update allows when user is author by authorId', async () => {
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue({ ...article, title: 'Updated' } as Article);

const result = await service.update('test-slug', { title: 'Updated' }, 'user1');
const result = await service.update('test-slug', { title: 'Updated' }, { id: 'id-1', name: 'user1' });

expect(articleRepository.updateBySlug).toHaveBeenCalledWith('test-slug', { title: 'Updated' });
expect(result).toEqual(expect.objectContaining({ title: 'Updated' }));
});

it('update falls back to author name for legacy docs without authorId', async () => {
const article = { ...baseArticle(), author: 'user1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue(article as Article);

await service.update('test-slug', { title: 'X' }, { id: 'some-id', name: 'user1' });

expect(articleRepository.updateBySlug).toHaveBeenCalled();
});

it('update allows when user is admin even if not author', async () => {
const article = { ...baseArticle(), author: 'other' };
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue(article as Article);

await service.update('test-slug', { title: 'Updated' }, 'admin-user', 'admin');
await service.update(
'test-slug',
{ title: 'Updated' },
{ id: 'id-admin', name: 'admin-user', role: 'admin' },
);

expect(articleRepository.updateBySlug).toHaveBeenCalledWith('test-slug', { title: 'Updated' });
});

it('delete allows when user is author', async () => {
const article = { ...baseArticle(), author: 'user1' };
it('delete allows when user is author by authorId', async () => {
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(true);

const result = await service.delete('test-slug', 'user1');
const result = await service.delete('test-slug', { id: 'id-1', name: 'user1' });

expect(result).toEqual({ success: true });
});

it('delete allows when user is admin even if not author', async () => {
const article = { ...baseArticle(), author: 'other' };
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(true);

const result = await service.delete('test-slug', 'admin-user', 'admin');
const result = await service.delete('test-slug', { id: 'id-admin', name: 'admin-user', role: 'admin' });

expect(result).toEqual({ success: true });
});
Expand All @@ -138,52 +152,56 @@ describe('ArticleService', () => {
expect(articleRepository.findBySlug).toHaveBeenCalledWith('missing');
});

it('create throws CONFLICT when slug already exists', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(baseArticle() as Article);
it('create throws CONFLICT when slug already exists (duplicate key error)', async () => {
const dupErr = Object.assign(new Error('E11000'), { code: 11000 });
vi.mocked(articleRepository.create).mockRejectedValue(dupErr);

await expect(service.create(baseArticle())).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Article with this slug already exists',
});
expect(articleRepository.create).not.toHaveBeenCalled();
});
});

describe('invalid input / permission', () => {
it('update throws FORBIDDEN when user is not author and not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('update throws FORBIDDEN when authorId does not match and not admin', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.update('test-slug', { title: 'Updated' }, 'random-user')).rejects.toMatchObject({
await expect(
service.update('test-slug', { title: 'X' }, { id: 'random-id', name: 'random-user' }),
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: 'You do not have permission to edit this article',
});
expect(articleRepository.updateBySlug).not.toHaveBeenCalled();
});

it('update throws FORBIDDEN when user is not author and userRole is not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('update throws FORBIDDEN when user role is not admin and not the author', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.update('test-slug', {}, 'random-user', 'user')).rejects.toMatchObject({
await expect(
service.update('test-slug', {}, { id: 'random-id', name: 'random-user', role: 'user' }),
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});

it('update throws NOT_FOUND when article does not exist', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(null);

await expect(service.update('missing', {}, 'user1')).rejects.toMatchObject({
await expect(service.update('missing', {}, { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Article not found',
});
});

it('delete throws FORBIDDEN when user is not author and not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('delete throws FORBIDDEN when authorId does not match and not admin', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.delete('test-slug', 'random-user')).rejects.toMatchObject({
await expect(service.delete('test-slug', { id: 'random-id', name: 'random-user' })).rejects.toMatchObject({
code: 'FORBIDDEN',
message: 'You do not have permission to delete this article',
});
Expand All @@ -193,18 +211,18 @@ describe('ArticleService', () => {
it('delete throws NOT_FOUND when article does not exist', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(null);

await expect(service.delete('missing', 'user1')).rejects.toMatchObject({
await expect(service.delete('missing', { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Article not found',
});
});

it('delete throws INTERNAL_SERVER_ERROR when deleteBySlug returns false', async () => {
const article = { ...baseArticle(), author: 'user1' };
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(false);

await expect(service.delete('test-slug', 'user1')).rejects.toMatchObject({
await expect(service.delete('test-slug', { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete article',
});
Expand Down
3 changes: 2 additions & 1 deletion src/entities/article/api/article.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArticleModel, type Article } from '~/entities/article/model/types';
import { connectDB } from '~/shared/lib/server/connection';
import { escapeRegex } from '~/shared/lib/escape-regex';
import { Types } from 'mongoose';

export interface ArticleQuery {
Expand Down Expand Up @@ -28,7 +29,7 @@ export class ArticleRepository {
}

if (title) {
filter.title = { $regex: title, $options: 'i' };
filter.title = { $regex: escapeRegex(title), $options: 'i' };
}

if (author) {
Expand Down
52 changes: 32 additions & 20 deletions src/entities/article/api/article.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
import { router, publicProcedure, protectedProcedure } from 'server/trpc';
import { articleService } from '~/entities/article/api/article.service';
import { getStorageClient } from '~/shared/lib/server/storage/factory';
import { log } from '~/shared/lib/server/logger';

const CONTENT_TYPE_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
Expand Down Expand Up @@ -44,7 +45,6 @@ export const articleRouter = router({
category: z.string().min(1),
img: z.string().optional(),
tags: z.array(z.string()).optional(),
content: z.any().optional(),
contentPm: z.record(z.string(), z.unknown()),
contentFormat: z.union([z.literal('editorjs'), z.literal('pm')]).optional(),
contentSchemaVersion: z.number().int().optional(),
Expand All @@ -54,9 +54,14 @@ export const articleRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'User ID not found' });
}
return await articleService.create({
...input,
author: ctx.session.user.name!,
author: ctx.session.user.name ?? '',
authorId: userId,
});
}),

Expand All @@ -70,7 +75,6 @@ export const articleRouter = router({
category: z.string().optional(),
img: z.string().optional(),
tags: z.array(z.string()).optional(),
content: z.any().optional(),
contentPm: z.record(z.string(), z.unknown()).optional().nullable(),
contentFormat: z.union([z.literal('editorjs'), z.literal('pm')]).optional(),
contentSchemaVersion: z.number().int().optional(),
Expand All @@ -80,22 +84,28 @@ export const articleRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'User ID not found' });
}
const { slug, ...updateData } = input;
return await articleService.update(
slug,
updateData,
ctx.session.user.name ?? '',
// TODO: why tf do we need to put role here?
(ctx.session.user as { role?: string }).role ?? undefined,
);
return await articleService.update(slug, updateData, {
id: userId,
name: ctx.session.user.name ?? undefined,
role: ctx.session.user.role ?? undefined,
});
}),

delete: protectedProcedure.input(z.object({ slug: z.string() })).mutation(async ({ input, ctx }) => {
return await articleService.delete(
input.slug,
ctx.session.user.name ?? '',
(ctx.session.user as { role?: string }).role ?? undefined,
);
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'User ID not found' });
}
return await articleService.delete(input.slug, {
id: userId,
name: ctx.session.user.name ?? undefined,
role: ctx.session.user.role,
});
}),

getAllSlugs: publicProcedure.query(async () => {
Expand Down Expand Up @@ -137,13 +147,15 @@ export const articleRouter = router({
const url = await storage.uploadFile(buffer, key, input.contentType);
return { url };
} catch (err) {
const message = err instanceof Error ? err.message : 'Storage upload failed';
log({
level: 'error',
message: 'cover image upload failed',
userId,
extra: { error: err instanceof Error ? err.message : String(err) },
});
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message:
message.includes('credentials') || message.includes('Storage')
? `${message}. Configure STORAGE_PROVIDER and S3/MinIO env vars.`
: message,
message: 'Failed to upload image. Please try again.',
});
}
}),
Expand Down
Loading
Loading