feat: full locations CRUD + legal policies in settings

Backend:
- CRUD completo para countries, regions y cities
- GET /settings/policy/:key — página HTML pública para políticas
- GET/PATCH /settings/policies/:key — admin endpoints protegidos

Admin:
- /locations: árbol interactivo País → Región → Ciudad con add/edit/delete
- /settings: editor de Política de Privacidad y Términos con enlace público copiable
- Sidebar: Ciudades → Ubicaciones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-27 07:45:53 -05:00
co-authored by Claude Sonnet 4.6
parent 5c740420e9
commit 39c4301d1b
9 changed files with 604 additions and 196 deletions
+56 -11
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { LocationsService } from './locations.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -8,25 +8,70 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
export class LocationsController {
constructor(private locations: LocationsService) {}
// Public reads
@Get('countries')
getCountries() {
return this.locations.getCountries();
}
getCountries() { return this.locations.getCountries(); }
@Get('countries/:countryId/regions')
getRegions(@Param('countryId') id: string) {
return this.locations.getRegions(id);
}
getRegions(@Param('countryId') id: string) { return this.locations.getRegions(id); }
@Get('regions/:regionId/cities')
getCities(@Param('regionId') id: string) {
return this.locations.getCities(id);
getCities(@Param('regionId') id: string) { return this.locations.getCities(id); }
// Countries CRUD
@Post('countries')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
createCountry(@Body() body: { name: string }) {
return this.locations.createCountry(body.name);
}
@Patch('countries/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
updateCountry(@Param('id') id: string, @Body() body: { name: string }) {
return this.locations.updateCountry(id, body.name);
}
@Delete('countries/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
deleteCountry(@Param('id') id: string) {
return this.locations.deleteCountry(id);
}
// Regions CRUD
@Post('regions')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
createRegion(@Body() body: { country_id: string; name: string }) {
return this.locations.createRegion(body.country_id, body.name);
}
@Patch('regions/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
updateRegion(@Param('id') id: string, @Body() body: { name: string }) {
return this.locations.updateRegion(id, body.name);
}
@Delete('regions/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
deleteRegion(@Param('id') id: string) {
return this.locations.deleteRegion(id);
}
// Cities CRUD
@Post('cities')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
return this.locations.createCity(body);
}
@Patch('cities/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
updateCity(@Param('id') id: string, @Body() body: { name?: string; latitude?: number; longitude?: number }) {
return this.locations.updateCity(id, body);
}
@Delete('cities/:id')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
deleteCity(@Param('id') id: string) {
return this.locations.deleteCity(id);
}
}
+64 -10
View File
@@ -6,28 +6,82 @@ export class LocationsService {
constructor(private prisma: PrismaService) {}
getCountries() {
return this.prisma.countries.findMany({ include: { regions: { include: { cities: true } } } });
return this.prisma.countries.findMany({
include: { regions: { include: { cities: true }, orderBy: { name: 'asc' } } },
orderBy: { name: 'asc' },
});
}
getRegions(countryId: string) {
return this.prisma.regions.findMany({ where: { country_id: countryId }, include: { cities: true } });
return this.prisma.regions.findMany({
where: { country_id: countryId },
include: { cities: { orderBy: { name: 'asc' } } },
orderBy: { name: 'asc' },
});
}
getCities(regionId: string) {
return this.prisma.cities.findMany({ where: { region_id: regionId } });
return this.prisma.cities.findMany({ where: { region_id: regionId }, orderBy: { name: 'asc' } });
}
// Countries
async createCountry(name: string) {
const existing = await this.prisma.countries.findUnique({ where: { name } });
if (existing) throw new ConflictException('Ya existe este país');
return this.prisma.countries.create({ data: { name } });
}
async updateCountry(id: string, name: string) {
const country = await this.prisma.countries.findUnique({ where: { id } });
if (!country) throw new NotFoundException('País no encontrado');
return this.prisma.countries.update({ where: { id }, data: { name } });
}
async deleteCountry(id: string) {
const country = await this.prisma.countries.findUnique({ where: { id } });
if (!country) throw new NotFoundException('País no encontrado');
return this.prisma.countries.delete({ where: { id } });
}
// Regions
async createRegion(countryId: string, name: string) {
const country = await this.prisma.countries.findUnique({ where: { id: countryId } });
if (!country) throw new NotFoundException('País no encontrado');
const existing = await this.prisma.regions.findFirst({ where: { country_id: countryId, name } });
if (existing) throw new ConflictException('Ya existe esta región en el país');
return this.prisma.regions.create({ data: { country_id: countryId, name } });
}
async updateRegion(id: string, name: string) {
const region = await this.prisma.regions.findUnique({ where: { id } });
if (!region) throw new NotFoundException('Región no encontrada');
return this.prisma.regions.update({ where: { id }, data: { name } });
}
async deleteRegion(id: string) {
const region = await this.prisma.regions.findUnique({ where: { id } });
if (!region) throw new NotFoundException('Región no encontrada');
return this.prisma.regions.delete({ where: { id } });
}
// Cities
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
if (!region) throw new NotFoundException('Región no encontrada');
const existing = await this.prisma.cities.findFirst({
where: { region_id: data.region_id, name: data.name },
});
const existing = await this.prisma.cities.findFirst({ where: { region_id: data.region_id, name: data.name } });
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
return this.prisma.cities.create({ data });
}
return this.prisma.cities.create({
data: { region_id: data.region_id, name: data.name, latitude: data.latitude, longitude: data.longitude },
});
async updateCity(id: string, data: { name?: string; latitude?: number; longitude?: number }) {
const city = await this.prisma.cities.findUnique({ where: { id } });
if (!city) throw new NotFoundException('Ciudad no encontrada');
return this.prisma.cities.update({ where: { id }, data });
}
async deleteCity(id: string) {
const city = await this.prisma.cities.findUnique({ where: { id } });
if (!city) throw new NotFoundException('Ciudad no encontrada');
return this.prisma.cities.delete({ where: { id } });
}
}
+67 -6
View File
@@ -1,5 +1,6 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Patch, Body, UseGuards, Param, Res } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -9,14 +10,74 @@ export class SettingsController {
constructor(private settings: SettingsService) {}
@Get()
getGlobal() {
return this.settings.getGlobal();
}
getGlobal() { return this.settings.getGlobal(); }
@Patch()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
updateGlobal(@Body() body: Record<string, any>) {
return this.settings.updateGlobal(body);
}
// Policies admin (protected)
@Get('policies')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
getPolicies() { return this.settings.getPolicies(); }
@Patch('policies/:key')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
updatePolicy(@Param('key') key: 'privacy' | 'terms', @Body() body: { content: string }) {
return this.settings.updatePolicy(key, body.content);
}
// Public policy pages
@Get('policy/:key')
async getPublicPolicy(@Param('key') key: string, @Res() res: Response) {
const content = await this.settings.getPolicy(key);
const titles: Record<string, string> = {
privacy: 'Politica de Privacidad',
terms: 'Terminos y Condiciones',
};
const title = titles[key] || 'Politica';
if (!content) {
return res.status(404).send(`<html><body><h1>${title}</h1><p>No disponible aun.</p></body></html>`);
}
const html = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - ProsApp</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; }
header { background: linear-gradient(135deg, #42A4EF, #1565C0); padding: 24px 0; text-align: center; }
header img { height: 40px; margin-bottom: 8px; }
header h1 { color: white; font-size: 1.5rem; font-weight: 700; }
.container { max-width: 800px; margin: 32px auto; padding: 0 16px 64px; }
.card { background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.content { white-space: pre-wrap; line-height: 1.8; font-size: 0.95rem; color: #374151; }
.content h1, .content h2, .content h3 { color: #1e293b; margin: 1.5em 0 0.5em; font-weight: 600; }
.content p { margin-bottom: 1em; }
footer { text-align: center; padding: 24px; color: #94a3b8; font-size: 0.8rem; }
</style>
</head>
<body>
<header>
<img src="https://prosapp.co/img/logo_prosapp.png" alt="ProsApp" onerror="this.style.display='none'">
<h1>${title}</h1>
</header>
<div class="container">
<div class="card">
<div class="content">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
</div>
<footer>© ${new Date().getFullYear()} ProsApp. Todos los derechos reservados.</footer>
</body>
</html>`;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(html);
}
}
+25 -1
View File
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
const POLICY_KEYS = ['privacy', 'terms'] as const;
type PolicyKey = typeof POLICY_KEYS[number];
@Injectable()
export class SettingsService {
constructor(private prisma: PrismaService) {}
@@ -17,4 +20,25 @@ export class SettingsService {
update: { value },
});
}
async getPolicy(key: string): Promise<string | null> {
const setting = await this.prisma.settings.findUnique({ where: { key: `policy_${key}` } });
return (setting?.value as any)?.content ?? null;
}
async updatePolicy(key: PolicyKey, content: string) {
return this.prisma.settings.upsert({
where: { key: `policy_${key}` },
create: { key: `policy_${key}`, value: { content } },
update: { value: { content } },
});
}
async getPolicies() {
const [privacy, terms] = await Promise.all([
this.getPolicy('privacy'),
this.getPolicy('terms'),
]);
return { privacy, terms };
}
}