diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d16c22a..779de3e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/app/global-error.tsx b/app/global-error.tsx
index cf2b797..0fbb7f4 100644
--- a/app/global-error.tsx
+++ b/app/global-error.tsx
@@ -7,6 +7,8 @@ import { Button } from '~/shared/ui/button';
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({
@@ -22,12 +24,12 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
- Something went wrong
+ Something went wrong / Что-то пошло не так
- {error.message || 'A critical error occurred.'}
+ {error.message || 'A critical error occurred. / Произошла критическая ошибка.'}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index dcee10e..86d9feb 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -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 (
404
- Page Not Found
+ Page Not Found / Страница не найдена
- The page you are looking for doesn't exist or has been moved.
+ The page you are looking for doesn't exist or has been moved. / Запрашиваемая страница не
+ существует или была перемещена.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3ba195f..f2f1c16 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -2,6 +2,8 @@ import { defineConfig, globalIgnores } from 'eslint/config';
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,
@@ -20,6 +22,26 @@ const eslintConfig = defineConfig([
'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': [
+ 'error',
+ { checksVoidReturn: { attributes: false } },
+ ],
+ },
+ },
// FSD layer boundaries: shared cannot import from higher layers
{
files: ['src/shared/**/*.ts', 'src/shared/**/*.tsx'],
diff --git a/server/trpc.ts b/server/trpc.ts
index e3b424e..7db2c43 100644
--- a/server/trpc.ts
+++ b/server/trpc.ts
@@ -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' });
diff --git a/src/entities/article/api/__tests__/article.service.test.ts b/src/entities/article/api/__tests__/article.service.test.ts
index 2eca3de..37e5b43 100644
--- a/src/entities/article/api/__tests__/article.service.test.ts
+++ b/src/entities/article/api/__tests__/article.service.test.ts
@@ -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 });
});
@@ -138,34 +152,38 @@ 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',
});
});
@@ -173,17 +191,17 @@ describe('ArticleService', () => {
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',
});
@@ -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',
});
diff --git a/src/entities/article/api/article.repository.ts b/src/entities/article/api/article.repository.ts
index 094d967..fc60719 100644
--- a/src/entities/article/api/article.repository.ts
+++ b/src/entities/article/api/article.repository.ts
@@ -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 {
@@ -28,7 +29,7 @@ export class ArticleRepository {
}
if (title) {
- filter.title = { $regex: title, $options: 'i' };
+ filter.title = { $regex: escapeRegex(title), $options: 'i' };
}
if (author) {
diff --git a/src/entities/article/api/article.router.ts b/src/entities/article/api/article.router.ts
index d068e24..70ce3c4 100644
--- a/src/entities/article/api/article.router.ts
+++ b/src/entities/article/api/article.router.ts
@@ -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 = {
'image/jpeg': 'jpg',
@@ -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(),
@@ -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,
});
}),
@@ -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(),
@@ -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 () => {
@@ -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.',
});
}
}),
diff --git a/src/entities/article/api/article.service.ts b/src/entities/article/api/article.service.ts
index b2aeb7c..be84bee 100644
--- a/src/entities/article/api/article.service.ts
+++ b/src/entities/article/api/article.service.ts
@@ -2,6 +2,13 @@ import { articleRepository, type ArticleQuery } from '~/entities/article/api/art
import { type Article } from '~/entities/article/model/types';
import { TRPCError } from '@trpc/server';
+// Falls back to author-name check for legacy docs created before `authorId` existed.
+function isOwnerOrAdmin(article: Article, actor: { id: string; name?: string; role?: string }): boolean {
+ if (actor.role === 'admin') return true;
+ if (article.authorId) return article.authorId === actor.id;
+ return !!actor.name && article.author === actor.name;
+}
+
export class ArticleService {
async findAll(query: ArticleQuery = {}) {
return await articleRepository.findAll(query);
@@ -19,26 +26,32 @@ export class ArticleService {
}
async create(articleData: Omit) {
- const existing = await articleRepository.findBySlug(articleData.slug);
- if (existing) {
- throw new TRPCError({
- code: 'CONFLICT',
- message: 'Article with this slug already exists',
- });
- }
-
const contentFormat = articleData.contentPm != null ? 'pm' : (articleData.contentFormat ?? 'pm');
const contentSchemaVersion = contentFormat === 'pm' ? 1 : undefined;
- return await articleRepository.create({
- ...articleData,
- contentFormat,
- contentSchemaVersion,
- createdAt: new Date(),
- });
+ try {
+ return await articleRepository.create({
+ ...articleData,
+ contentFormat,
+ contentSchemaVersion,
+ createdAt: new Date(),
+ });
+ } catch (err) {
+ if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 11000) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Article with this slug already exists',
+ });
+ }
+ throw err;
+ }
}
- async update(slug: string, updateData: Partial, userId: string, userRole?: string) {
+ async update(
+ slug: string,
+ updateData: Partial,
+ actor: { id: string; name?: string; role?: string },
+ ) {
const article = await articleRepository.findBySlug(slug);
if (!article) {
throw new TRPCError({
@@ -47,7 +60,7 @@ export class ArticleService {
});
}
- if (article.author !== userId && userRole !== 'admin') {
+ if (!isOwnerOrAdmin(article, actor)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to edit this article',
@@ -57,7 +70,7 @@ export class ArticleService {
return await articleRepository.updateBySlug(slug, updateData);
}
- async delete(slug: string, userId: string, userRole?: string) {
+ async delete(slug: string, actor: { id: string; name?: string; role?: string }) {
const article = await articleRepository.findBySlug(slug);
if (!article) {
throw new TRPCError({
@@ -66,7 +79,7 @@ export class ArticleService {
});
}
- if (article.author !== userId && userRole !== 'admin') {
+ if (!isOwnerOrAdmin(article, actor)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to delete this article',
diff --git a/src/entities/article/model/types.ts b/src/entities/article/model/types.ts
index 5756eb6..1689a0f 100644
--- a/src/entities/article/model/types.ts
+++ b/src/entities/article/model/types.ts
@@ -11,7 +11,10 @@ export interface Article {
title: string;
description: string;
category: string;
+ /** Display name of the author (used in URLs and UI). */
author: string;
+ /** Stable user id used for ownership/auth checks. Optional for legacy docs created before this field existed. */
+ authorId?: string;
createdAt: Date;
editedAt?: Date;
img?: string;
@@ -26,14 +29,15 @@ export interface Article {
}
const ArticleSchema = new Schema({
- slug: { type: String, required: true, unique: true },
+ slug: { type: String, required: true, unique: true, index: true },
title: { type: String, required: true },
description: { type: String, required: true },
- category: { type: String, required: true },
- author: { type: String, required: true },
- createdAt: { type: Date, required: true },
+ category: { type: String, required: true, index: true },
+ author: { type: String, required: true, index: true },
+ authorId: { type: String, required: false, index: true },
+ createdAt: { type: Date, required: true, index: true },
img: String,
- tags: [String],
+ tags: { type: [String], index: true },
contentPm: { type: Schema.Types.Mixed, required: false },
contentFormat: { type: String, enum: ['editorjs', 'pm'], required: false },
contentSchemaVersion: { type: Number, required: false },
diff --git a/src/entities/article/ui/article-item.tsx b/src/entities/article/ui/article-item.tsx
index 6d48fea..b422887 100644
--- a/src/entities/article/ui/article-item.tsx
+++ b/src/entities/article/ui/article-item.tsx
@@ -3,7 +3,7 @@
import { authClient } from '~/shared/api/auth-client';
import type { SessionUser } from '~/shared/types/session';
import Image from 'next/image';
-import { useTranslations } from 'next-intl';
+import { useLocale, useTranslations } from 'next-intl';
import { Link, useRouter } from 'i18n/navigation';
import { useState } from 'react';
import type { Article } from '~/entities/article/model/types';
@@ -20,6 +20,9 @@ export function ArticleItem(props: Article) {
const img = props.img ?? `/img/${props.category}.png`;
const { data: session } = authClient.useSession();
const tCategory = useTranslations('category');
+ const tArticles = useTranslations('articles');
+ const tButton = useTranslations('button');
+ const locale = useLocale();
const [dialogOpen, setDialogOpen] = useState(false);
const router = useRouter();
const { toast } = useToast();
@@ -27,15 +30,15 @@ export function ArticleItem(props: Article) {
const deleteMutation = trpc.article.delete.useMutation({
onSuccess: () => {
toast({
- title: 'Success',
- description: 'Article deleted successfully',
+ title: tArticles('deleteSuccessTitle'),
+ description: tArticles('deleteSuccessDescription'),
});
router.refresh();
setDialogOpen(false);
},
onError: (error) => {
toast({
- title: 'Error',
+ title: tArticles('deleteErrorTitle'),
description: error.message,
variant: 'destructive',
});
@@ -43,7 +46,8 @@ export function ArticleItem(props: Article) {
});
const user = session?.user as SessionUser | undefined;
- const canEdit = user && (user.name === props.author || user.role === 'admin');
+ const isOwner = user && (props.authorId ? props.authorId === user.id : props.author === user.name);
+ const canEdit = !!user && (isOwner || user.role === 'admin');
const handleDelete = () => {
deleteMutation.mutate({ slug: props.slug });
@@ -54,17 +58,15 @@ export function ArticleItem(props: Article) {