File size: 6,595 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpException,
  Param,
  Post,
  Put,
  Query,
  UseGuards,
} from '@nestjs/common';
import type { ChannelTestResult, UnreadCountResult } from '@trek/shared';
import type { User } from '../../types';
import { NotificationsService } from './notifications.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';

// The masked placeholder the client sends instead of a stored secret (8× U+2022).
const MASKED = '••••••••';

/**
 * /api/notifications — channel-preference matrix, channel test pings, and in-app
 * notifications.
 *
 * Byte-identical to the legacy Express route (server/src/routes/notifications.ts):
 * same auth, the same inline admin gate on /test-smtp (note: it returns
 * { error: 'Admin only' }, NOT the AdminGuard's wording), the same webhook/ntfy
 * fallback resolution, the same id parsing + 400/404 bodies, and the same status
 * codes. POSTs that answer with res.json stay 200 (Nest would default to 201).
 * The static /in-app/read-all and /in-app/all routes are declared before the
 * /in-app/:id routes so they win over the param, matching the legacy order.
 */
@Controller('api/notifications')
@UseGuards(JwtAuthGuard)
export class NotificationsController {
  constructor(private readonly notifications: NotificationsService) {}

  @Get('preferences')
  getPreferences(@CurrentUser() user: User) {
    return this.notifications.getPreferences(user.id, user.role);
  }

  @Put('preferences')
  setPreferences(@CurrentUser() user: User, @Body() body: Record<string, Record<string, boolean>>) {
    this.notifications.setPreferences(user.id, body);
    return this.notifications.getPreferences(user.id, user.role);
  }

  @Post('test-smtp')
  @HttpCode(200)
  async testSmtp(@CurrentUser() user: User, @Body('email') email?: string): Promise<ChannelTestResult> {
    if (user.role !== 'admin') {
      throw new HttpException({ error: 'Admin only' }, 403);
    }
    return this.notifications.testSmtp(email || user.email);
  }

  @Post('test-webhook')
  @HttpCode(200)
  async testWebhook(@CurrentUser() user: User, @Body('url') urlInput?: unknown): Promise<ChannelTestResult> {
    let url = urlInput;
    if (!url || url === MASKED) {
      url = this.notifications.userWebhookUrl(user.id);
      if (!url && user.role === 'admin') url = this.notifications.adminWebhookUrl();
      if (!url) {
        throw new HttpException({ error: 'No webhook URL configured' }, 400);
      }
    }
    if (typeof url !== 'string') {
      throw new HttpException({ error: 'url must be a string' }, 400);
    }
    try {
      new URL(url);
    } catch {
      throw new HttpException({ error: 'Invalid URL' }, 400);
    }
    return this.notifications.testWebhook(url);
  }

  @Post('test-ntfy')
  @HttpCode(200)
  async testNtfy(
    @CurrentUser() user: User,
    @Body('topic') topic?: string,
    @Body('server') server?: string,
    @Body('token') token?: string,
  ): Promise<ChannelTestResult> {
    const userCfg = this.notifications.userNtfyConfig(user.id);
    const adminCfg = this.notifications.adminNtfyConfig();

    const resolvedTopic = topic || userCfg?.topic || undefined;
    const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
    // Reuse the saved token when the request sends null, empty, or the masked placeholder.
    const resolvedToken = (token && token !== MASKED)
      ? token
      : (userCfg?.token ?? adminCfg.token ?? null);

    if (!resolvedTopic) {
      throw new HttpException({ error: 'No ntfy topic configured' }, 400);
    }
    return this.notifications.testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken });
  }

  @Get('in-app')
  listInApp(
    @CurrentUser() user: User,
    @Query('limit') limit?: string,
    @Query('offset') offset?: string,
    @Query('unread_only') unreadOnly?: string,
  ) {
    return this.notifications.listInApp(user.id, {
      limit: Math.min(parseInt(limit as string) || 20, 50),
      offset: parseInt(offset as string) || 0,
      unreadOnly: unreadOnly === 'true',
    });
  }

  @Get('in-app/unread-count')
  unreadCount(@CurrentUser() user: User): UnreadCountResult {
    return { count: this.notifications.unreadCount(user.id) };
  }

  @Put('in-app/read-all')
  readAll(@CurrentUser() user: User): { success: boolean; count: number } {
    return { success: true, count: this.notifications.markAllRead(user.id) };
  }

  @Delete('in-app/all')
  deleteAll(@CurrentUser() user: User): { success: boolean; count: number } {
    return { success: true, count: this.notifications.deleteAll(user.id) };
  }

  @Put('in-app/:id/read')
  markRead(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
    const id = this.parseId(idParam);
    if (!this.notifications.markRead(id, user.id)) {
      throw new HttpException({ error: 'Not found' }, 404);
    }
    return { success: true };
  }

  @Put('in-app/:id/unread')
  markUnread(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
    const id = this.parseId(idParam);
    if (!this.notifications.markUnread(id, user.id)) {
      throw new HttpException({ error: 'Not found' }, 404);
    }
    return { success: true };
  }

  @Delete('in-app/:id')
  deleteOne(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
    const id = this.parseId(idParam);
    if (!this.notifications.deleteOne(id, user.id)) {
      throw new HttpException({ error: 'Not found' }, 404);
    }
    return { success: true };
  }

  @Post('in-app/:id/respond')
  @HttpCode(200)
  async respond(
    @CurrentUser() user: User,
    @Param('id') idParam: string,
    @Body('response') response?: unknown,
  ): Promise<{ success: boolean; notification: unknown }> {
    const id = this.parseId(idParam);
    if (response !== 'positive' && response !== 'negative') {
      throw new HttpException({ error: 'response must be "positive" or "negative"' }, 400);
    }
    const result = await this.notifications.respond(id, user.id, response);
    if (!result.success) {
      throw new HttpException({ error: result.error }, 400);
    }
    return { success: true, notification: result.notification };
  }

  /** parseInt + the legacy "Invalid id" 400 guard, shared by the /:id handlers. */
  private parseId(idParam: string): number {
    const id = parseInt(idParam);
    if (isNaN(id)) {
      throw new HttpException({ error: 'Invalid id' }, 400);
    }
    return id;
  }
}