feat: Implement Legal Pages CMS backend module
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Add comprehensive legal pages management system with: - Database schema (legal_pages, legal_page_versions tables) - TypeORM entities with full relationship support - CRUD operations with version history tracking - Publish/unpublish functionality - Revert to previous versions - Multi-language support (en, es, fr, pt, zh) - Public API endpoint for published pages - Admin-only endpoints with AdminGuard protection - Seed script with initial legal content (Privacy, Terms, EULA, Cookies) Database tables created in both dev and prod environments. Initial content seeded with 4 legal pages in English. API endpoints: - GET /api/v1/legal-pages/:slug (public, published pages) - GET /api/v1/admin/legal-pages (list all with filters) - POST /api/v1/admin/legal-pages (create new page) - PUT /api/v1/admin/legal-pages/:id (update page, creates version) - PATCH /api/v1/admin/legal-pages/:id/publish (publish/unpublish) - DELETE /api/v1/admin/legal-pages/:id (delete page) - GET /api/v1/admin/legal-pages/:id/versions (version history) - POST /api/v1/admin/legal-pages/:id/revert (revert to version) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { LegalPage } from './legal-page.entity';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('legal_page_versions')
|
||||
@Index(['legalPageId', 'version'], { unique: true })
|
||||
export class LegalPageVersion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'legal_page_id', type: 'uuid' })
|
||||
@Index()
|
||||
legalPageId: string;
|
||||
|
||||
@ManyToOne(() => LegalPage, legalPage => legalPage.versions, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'legal_page_id' })
|
||||
legalPage: LegalPage;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
version: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'created_by', type: 'varchar', length: 20, nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdByUser: User;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
@Index()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { LegalPageVersion } from './legal-page-version.entity';
|
||||
|
||||
@Entity('legal_pages')
|
||||
@Index(['slug', 'language'], { unique: true })
|
||||
export class LegalPage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
@Index()
|
||||
slug: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 5, default: 'en' })
|
||||
@Index()
|
||||
language: string;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
version: number;
|
||||
|
||||
@Column({ name: 'is_published', type: 'boolean', default: false })
|
||||
@Index()
|
||||
isPublished: boolean;
|
||||
|
||||
@Column({ name: 'last_updated_by', type: 'varchar', length: 20, nullable: true })
|
||||
lastUpdatedBy: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'last_updated_by' })
|
||||
lastUpdatedByUser: User;
|
||||
|
||||
@OneToMany(() => LegalPageVersion, version => version.legalPage, {
|
||||
cascade: true,
|
||||
})
|
||||
versions: LegalPageVersion[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateLegalPagesTables1728408000000 implements MigrationInterface {
|
||||
name = 'CreateLegalPagesTables1728408000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create legal_pages table
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE legal_pages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
language VARCHAR(5) DEFAULT 'en',
|
||||
version INTEGER DEFAULT 1,
|
||||
is_published BOOLEAN DEFAULT false,
|
||||
last_updated_by VARCHAR(20) REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT unique_slug_language UNIQUE(slug, language)
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for legal_pages
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_legal_pages_slug ON legal_pages(slug);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_legal_pages_published ON legal_pages(is_published) WHERE is_published = true;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_legal_pages_language ON legal_pages(language);
|
||||
`);
|
||||
|
||||
// Create legal_page_versions table for version history
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE legal_page_versions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
legal_page_id UUID NOT NULL REFERENCES legal_pages(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_by VARCHAR(20) REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT unique_page_version UNIQUE(legal_page_id, version)
|
||||
);
|
||||
`);
|
||||
|
||||
// Create index for version history lookups
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_legal_page_versions_page_id ON legal_page_versions(legal_page_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_legal_page_versions_created_at ON legal_page_versions(created_at DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop indexes first
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_legal_page_versions_created_at;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_legal_page_versions_page_id;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_legal_pages_language;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_legal_pages_published;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_legal_pages_slug;`);
|
||||
|
||||
// Drop tables
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS legal_page_versions;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS legal_pages;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export async function seedLegalPages(dataSource: DataSource) {
|
||||
const legalPagesRepo = dataSource.getRepository('legal_pages');
|
||||
const legalPageVersionsRepo = dataSource.getRepository('legal_page_versions');
|
||||
|
||||
// Get admin user (demo admin)
|
||||
const usersRepo = dataSource.getRepository('users');
|
||||
const adminUser = await usersRepo.findOne({
|
||||
where: { email: 'demo@parentflowapp.com' },
|
||||
});
|
||||
|
||||
const adminUserId = adminUser?.id || null;
|
||||
|
||||
const legalPages = [
|
||||
{
|
||||
slug: 'privacy',
|
||||
title: 'Privacy Policy',
|
||||
language: 'en',
|
||||
content: `# Privacy Policy
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
Welcome to ParentFlow ("we," "our," or "us"). We are committed to protecting your privacy and the privacy of your children. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our mobile application and related services (collectively, the "Service").
|
||||
|
||||
Because our Service is designed for parents and caregivers tracking information about children aged 0-6 years, we take extra precautions to comply with the Children's Online Privacy Protection Act (COPPA) and the General Data Protection Regulation (GDPR).
|
||||
|
||||
## 2. Information We Collect
|
||||
|
||||
### 2.1 Personal Information You Provide
|
||||
|
||||
- **Account Information:** Name, email address, date of birth (for COPPA age verification)
|
||||
- **Profile Information:** Profile photo, timezone, language preferences
|
||||
- **Child Information:** Child's name, date of birth, gender, photo (optional)
|
||||
- **Activity Data:** Feeding times, sleep schedules, diaper changes, medication records, milestones
|
||||
- **AI Chat Messages:** Questions and conversations with our AI assistant
|
||||
- **Photos and Media:** Photos of children and milestones (optional)
|
||||
|
||||
### 2.2 Automatically Collected Information
|
||||
|
||||
- **Device Information:** Device type, operating system, unique device identifiers
|
||||
- **Usage Data:** App features used, session duration, error logs
|
||||
- **Technical Data:** IP address, browser type, time zone settings
|
||||
|
||||
## 3. How We Use Your Information
|
||||
|
||||
We use the collected information for:
|
||||
|
||||
- Providing and maintaining the Service
|
||||
- Tracking your child's activities and patterns
|
||||
- Generating insights and analytics about your child's development
|
||||
- Providing AI-powered parenting support and answers
|
||||
- Syncing data across family members' devices
|
||||
- Sending notifications and reminders
|
||||
- Improving our Service and developing new features
|
||||
- Detecting and preventing fraud and security issues
|
||||
- Complying with legal obligations
|
||||
|
||||
## 4. Children's Privacy (COPPA Compliance)
|
||||
|
||||
Our Service is designed for parents and caregivers to track information about their children. We do not knowingly collect personal information directly from children under 13 years of age.
|
||||
|
||||
**Parental Rights:**
|
||||
|
||||
- Review your child's information
|
||||
- Request deletion of your child's information
|
||||
- Refuse further collection or use of your child's information
|
||||
- Export your child's data in a portable format
|
||||
|
||||
To exercise these rights, please contact us at **hello@parentflow.com**.
|
||||
|
||||
## 5. Data Sharing and Disclosure
|
||||
|
||||
**We do NOT sell your personal information or your child's information.**
|
||||
|
||||
We may share information with:
|
||||
|
||||
- **Family Members:** Data is shared with family members you invite to your family group
|
||||
- **Service Providers:** Cloud hosting (AWS/Azure), analytics (anonymized), customer support
|
||||
- **AI Providers:** OpenAI or Anthropic (for AI chat, with no PII in training data)
|
||||
- **Legal Compliance:** When required by law or to protect rights and safety
|
||||
|
||||
## 6. Data Security
|
||||
|
||||
We implement industry-standard security measures to protect your information:
|
||||
|
||||
- End-to-end encryption for sensitive child data
|
||||
- Secure HTTPS connections for all communications
|
||||
- Regular security audits and penetration testing
|
||||
- Access controls and authentication mechanisms
|
||||
- Encrypted database storage
|
||||
|
||||
## 7. Your Rights (GDPR Compliance)
|
||||
|
||||
Under GDPR, you have the following rights:
|
||||
|
||||
- **Right to Access:** Request a copy of your personal data
|
||||
- **Right to Rectification:** Correct inaccurate information
|
||||
- **Right to Erasure:** Request deletion of your data ("right to be forgotten")
|
||||
- **Right to Data Portability:** Export your data in a machine-readable format
|
||||
- **Right to Restrict Processing:** Limit how we use your data
|
||||
- **Right to Object:** Opt-out of certain data processing
|
||||
- **Right to Withdraw Consent:** Revoke consent at any time
|
||||
|
||||
To exercise your rights, visit Settings → Privacy → Data Rights or email **hello@parentflow.com**.
|
||||
|
||||
## 8. Contact Us
|
||||
|
||||
If you have questions about this Privacy Policy or our data practices, please contact us:
|
||||
|
||||
**Email:** hello@parentflow.com
|
||||
**Address:** Serbota 3, Bucharest, Romania`,
|
||||
isPublished: true,
|
||||
},
|
||||
{
|
||||
slug: 'terms',
|
||||
title: 'Terms of Service',
|
||||
language: 'en',
|
||||
content: `# Terms of Service
|
||||
|
||||
## 1. Acceptance of Terms
|
||||
|
||||
By accessing or using ParentFlow (the "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, do not use the Service.
|
||||
|
||||
These Terms constitute a legally binding agreement between you and ParentFlow ("we," "us," or "our").
|
||||
|
||||
## 2. Description of Service
|
||||
|
||||
ParentFlow is a parenting organization and tracking application designed to help parents and caregivers manage childcare for children aged 0-6 years. The Service includes:
|
||||
|
||||
- Activity tracking (feeding, sleep, diapers, medicine, milestones)
|
||||
- AI-powered parenting support and guidance
|
||||
- Family synchronization and collaboration tools
|
||||
- Analytics and insights about your child's patterns
|
||||
- Voice input capabilities
|
||||
- Photo storage and milestone tracking
|
||||
|
||||
## 3. User Accounts
|
||||
|
||||
### 3.1 Account Creation
|
||||
|
||||
To use the Service, you must create an account. You agree to:
|
||||
|
||||
- Provide accurate and complete information
|
||||
- Maintain the security of your account credentials
|
||||
- Notify us immediately of any unauthorized access
|
||||
- Be responsible for all activities under your account
|
||||
|
||||
### 3.2 Age Requirements
|
||||
|
||||
You must be at least 18 years old (or the age of majority in your jurisdiction) to create an account. Users between 13-17 years old may only use the Service with parental consent.
|
||||
|
||||
## 4. Acceptable Use
|
||||
|
||||
You agree NOT to:
|
||||
|
||||
- Use the Service for any illegal purpose
|
||||
- Violate any laws or regulations
|
||||
- Infringe on intellectual property rights
|
||||
- Upload malicious code or viruses
|
||||
- Attempt to gain unauthorized access to our systems
|
||||
- Harass, abuse, or harm other users
|
||||
- Share inappropriate content involving minors
|
||||
- Use automated tools to access the Service (bots, scrapers)
|
||||
- Reverse engineer or decompile the Service
|
||||
|
||||
## 5. Medical Disclaimer
|
||||
|
||||
**THE SERVICE IS NOT A SUBSTITUTE FOR PROFESSIONAL MEDICAL ADVICE.**
|
||||
|
||||
Our AI assistant and tracking features provide general information and insights only. They do not constitute medical advice, diagnosis, or treatment. Always consult with qualified healthcare professionals for medical concerns.
|
||||
|
||||
**In case of emergency, call your local emergency services immediately.**
|
||||
|
||||
## 6. Intellectual Property
|
||||
|
||||
The Service, including its design, features, code, and content (excluding user content), is owned by ParentFlow and protected by copyright, trademark, and other intellectual property laws.
|
||||
|
||||
You may not copy, modify, distribute, sell, or create derivative works based on the Service without our written permission.
|
||||
|
||||
## 7. Termination
|
||||
|
||||
### 7.1 Termination by You
|
||||
|
||||
You may terminate your account at any time through the app settings. Upon termination, your data will be deleted in accordance with our Privacy Policy.
|
||||
|
||||
### 7.2 Termination by Us
|
||||
|
||||
We reserve the right to suspend or terminate your account if you violate these Terms or engage in harmful behavior. We will provide notice where reasonably possible.
|
||||
|
||||
## 8. Disclaimers
|
||||
|
||||
**THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND.**
|
||||
|
||||
We disclaim all warranties, express or implied, including warranties of merchantability, fitness for a particular purpose, and non-infringement. We do not guarantee that the Service will be error-free, secure, or uninterrupted.
|
||||
|
||||
## 9. Limitation of Liability
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SERVICE.
|
||||
|
||||
Our total liability shall not exceed the amount you paid us in the 12 months preceding the claim, or $100, whichever is greater.
|
||||
|
||||
## 10. Contact Us
|
||||
|
||||
If you have questions about these Terms, please contact us:
|
||||
|
||||
**Email:** hello@parentflow.com
|
||||
**Address:** Serbota 3, Bucharest, Romania`,
|
||||
isPublished: true,
|
||||
},
|
||||
{
|
||||
slug: 'eula',
|
||||
title: 'End User License Agreement (EULA)',
|
||||
language: 'en',
|
||||
content: `# End User License Agreement (EULA)
|
||||
|
||||
**Last Updated:** October 8, 2025
|
||||
|
||||
## 1. Agreement to Terms
|
||||
|
||||
This End User License Agreement ("EULA") is a legal agreement between you and ParentFlow for the use of the ParentFlow mobile application and related services (the "Application").
|
||||
|
||||
By installing, accessing, or using the Application, you agree to be bound by the terms of this EULA.
|
||||
|
||||
## 2. License Grant
|
||||
|
||||
Subject to your compliance with this EULA, ParentFlow grants you a limited, non-exclusive, non-transferable, revocable license to:
|
||||
|
||||
- Download, install, and use the Application on devices you own or control
|
||||
- Access and use the Application's features and services
|
||||
|
||||
## 3. License Restrictions
|
||||
|
||||
You may NOT:
|
||||
|
||||
- Copy, modify, or create derivative works of the Application
|
||||
- Reverse engineer, decompile, or disassemble the Application
|
||||
- Remove, alter, or obscure any copyright, trademark, or other proprietary notices
|
||||
- Rent, lease, lend, sell, redistribute, or sublicense the Application
|
||||
- Use the Application for any unlawful purpose
|
||||
- Interfere with or disrupt the Application or servers
|
||||
- Attempt to gain unauthorized access to any part of the Application
|
||||
|
||||
## 4. Intellectual Property
|
||||
|
||||
The Application and all rights, including intellectual property rights, are owned by ParentFlow. This EULA does not grant you any rights to ParentFlow's trademarks, service marks, or logos.
|
||||
|
||||
## 5. User Content
|
||||
|
||||
You retain ownership of content you create or upload through the Application. By using the Application, you grant ParentFlow a license to use, store, and process your content to provide the services.
|
||||
|
||||
## 6. Privacy
|
||||
|
||||
Your use of the Application is subject to ParentFlow's Privacy Policy. Please review our Privacy Policy to understand how we collect, use, and protect your information.
|
||||
|
||||
## 7. Updates and Modifications
|
||||
|
||||
ParentFlow may provide updates, patches, or modifications to the Application. These updates may be automatically downloaded and installed. You agree to receive such updates as part of your use of the Application.
|
||||
|
||||
## 8. Termination
|
||||
|
||||
This EULA is effective until terminated. Your rights under this EULA will terminate automatically if you fail to comply with any of its terms. Upon termination, you must cease all use of the Application and delete all copies.
|
||||
|
||||
## 9. Disclaimer of Warranties
|
||||
|
||||
THE APPLICATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. PARENTFLOW DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
||||
|
||||
## 10. Limitation of Liability
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PARENTFLOW SHALL NOT BE LIABLE FOR ANY DAMAGES ARISING FROM YOUR USE OF THE APPLICATION, INCLUDING DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES.
|
||||
|
||||
## 11. Governing Law
|
||||
|
||||
This EULA shall be governed by and construed in accordance with the laws of Romania, without regard to its conflict of law provisions.
|
||||
|
||||
## 12. Contact Information
|
||||
|
||||
If you have any questions about this EULA, please contact us:
|
||||
|
||||
**Email:** hello@parentflow.com
|
||||
**Address:** Serbota 3, Bucharest, Romania`,
|
||||
isPublished: true,
|
||||
},
|
||||
{
|
||||
slug: 'cookies',
|
||||
title: 'Cookie Policy',
|
||||
language: 'en',
|
||||
content: `# Cookie Policy
|
||||
|
||||
**Last Updated:** October 8, 2025
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
This Cookie Policy explains how ParentFlow ("we," "us," or "our") uses cookies and similar technologies when you use our website and mobile application (the "Service").
|
||||
|
||||
## 2. What Are Cookies?
|
||||
|
||||
Cookies are small text files stored on your device (computer, smartphone, or tablet) when you visit a website or use an application. They help us provide you with a better experience by remembering your preferences and improving our Service.
|
||||
|
||||
## 3. Types of Cookies We Use
|
||||
|
||||
### 3.1 Essential Cookies
|
||||
|
||||
These cookies are necessary for the Service to function properly. They enable core functionality such as security, authentication, and accessibility.
|
||||
|
||||
- **Session cookies:** Maintain your login session
|
||||
- **Security cookies:** Protect against unauthorized access
|
||||
- **Authentication cookies:** Verify your identity
|
||||
|
||||
### 3.2 Performance Cookies
|
||||
|
||||
These cookies help us understand how you use the Service, allowing us to improve its performance and functionality.
|
||||
|
||||
- **Analytics cookies:** Track usage patterns and feature adoption
|
||||
- **Error tracking cookies:** Help us identify and fix technical issues
|
||||
|
||||
### 3.3 Functional Cookies
|
||||
|
||||
These cookies remember your preferences and choices to provide a personalized experience.
|
||||
|
||||
- **Language preference cookies**
|
||||
- **Theme/appearance cookies**
|
||||
- **Timezone cookies**
|
||||
|
||||
### 3.4 Advertising Cookies
|
||||
|
||||
We do not currently use advertising cookies. If this changes in the future, we will update this policy and obtain your consent where required.
|
||||
|
||||
## 4. How We Use Cookies
|
||||
|
||||
We use cookies for the following purposes:
|
||||
|
||||
- **Authentication:** Keep you logged in between sessions
|
||||
- **Security:** Protect your account from unauthorized access
|
||||
- **Preferences:** Remember your language, timezone, and other settings
|
||||
- **Analytics:** Understand how you use our Service to make improvements
|
||||
- **Performance:** Optimize load times and functionality
|
||||
- **Error tracking:** Identify and resolve technical issues
|
||||
|
||||
## 5. Third-Party Cookies
|
||||
|
||||
We may use third-party services that set cookies on your device:
|
||||
|
||||
- **Analytics providers:** Google Analytics (anonymized)
|
||||
- **Cloud services:** AWS, Azure
|
||||
- **Error tracking:** Sentry
|
||||
|
||||
These third parties have their own privacy policies governing their use of cookies.
|
||||
|
||||
## 6. Managing Cookies
|
||||
|
||||
You can control and manage cookies in several ways:
|
||||
|
||||
### 6.1 Browser Settings
|
||||
|
||||
Most web browsers allow you to:
|
||||
|
||||
- View and delete existing cookies
|
||||
- Block third-party cookies
|
||||
- Block all cookies (note: this may affect Service functionality)
|
||||
- Clear cookies when you close your browser
|
||||
|
||||
### 6.2 Mobile App Settings
|
||||
|
||||
In our mobile app, you can manage cookie-like preferences through:
|
||||
|
||||
- App Settings → Privacy → Cookie Preferences
|
||||
- Device Settings → Apps → ParentFlow → Permissions
|
||||
|
||||
### 6.3 Opt-Out Tools
|
||||
|
||||
You can opt out of analytics tracking:
|
||||
|
||||
- Google Analytics: [https://tools.google.com/dlpage/gaoptout](https://tools.google.com/dlpage/gaoptout)
|
||||
|
||||
## 7. Do Not Track Signals
|
||||
|
||||
Some browsers include a "Do Not Track" (DNT) feature. Currently, there is no industry standard for how to respond to DNT signals. We do not currently respond to DNT signals, but we will update this policy if standards are established.
|
||||
|
||||
## 8. Cookie Duration
|
||||
|
||||
- **Session cookies:** Deleted when you close your browser/app
|
||||
- **Persistent cookies:** Remain until expiration date or manual deletion
|
||||
- **Our persistent cookies typically last:** 1 year
|
||||
|
||||
## 9. Changes to This Cookie Policy
|
||||
|
||||
We may update this Cookie Policy from time to time. We will notify you of significant changes by email or through the Service. Your continued use after changes constitutes acceptance of the updated policy.
|
||||
|
||||
## 10. Contact Us
|
||||
|
||||
If you have questions about our use of cookies, please contact us:
|
||||
|
||||
**Email:** hello@parentflow.com
|
||||
**Address:** Serbota 3, Bucharest, Romania`,
|
||||
isPublished: true,
|
||||
},
|
||||
];
|
||||
|
||||
console.log('Seeding legal pages...');
|
||||
|
||||
for (const pageData of legalPages) {
|
||||
// Check if already exists
|
||||
const existing = await legalPagesRepo.findOne({
|
||||
where: { slug: pageData.slug, language: pageData.language },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log(`Legal page "${pageData.slug}" (${pageData.language}) already exists, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert legal page
|
||||
const insertResult = await legalPagesRepo.insert({
|
||||
slug: pageData.slug,
|
||||
title: pageData.title,
|
||||
content: pageData.content,
|
||||
language: pageData.language,
|
||||
version: 1,
|
||||
is_published: pageData.isPublished,
|
||||
last_updated_by: adminUserId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
const pageId = insertResult.identifiers[0].id;
|
||||
|
||||
// Insert initial version
|
||||
await legalPageVersionsRepo.insert({
|
||||
legal_page_id: pageId,
|
||||
version: 1,
|
||||
title: pageData.title,
|
||||
content: pageData.content,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
console.log(`✓ Created legal page: "${pageData.title}" (${pageData.slug})`);
|
||||
}
|
||||
|
||||
console.log('Legal pages seeding completed!');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { seedLegalPages } from './legal-pages.seed';
|
||||
|
||||
async function runSeed() {
|
||||
const dataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST || '10.0.0.207',
|
||||
port: parseInt(process.env.DATABASE_PORT || '5432'),
|
||||
username: process.env.DATABASE_USER || 'postgres',
|
||||
password: process.env.DATABASE_PASSWORD || 'a3ppq',
|
||||
database: process.env.DATABASE_NAME || 'parentflowdev',
|
||||
entities: [],
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
console.log('Database connection established');
|
||||
|
||||
await seedLegalPages(dataSource);
|
||||
|
||||
console.log('Seed completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error running seed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
runSeed();
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { UserManagementModule } from './user-management/user-management.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { FamiliesModule } from './families/families.module';
|
||||
import { LegalPagesModule } from './legal-pages/legal-pages.module';
|
||||
|
||||
@Module({
|
||||
imports: [UserManagementModule, DashboardModule, FamiliesModule],
|
||||
exports: [UserManagementModule, DashboardModule, FamiliesModule],
|
||||
imports: [UserManagementModule, DashboardModule, FamiliesModule, LegalPagesModule],
|
||||
exports: [UserManagementModule, DashboardModule, FamiliesModule, LegalPagesModule],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { LegalPagesService } from './legal-pages.service';
|
||||
import { AdminGuard } from '../../../common/guards/admin.guard';
|
||||
import { Public } from '../../auth/decorators/public.decorator';
|
||||
import {
|
||||
CreateLegalPageDto,
|
||||
UpdateLegalPageDto,
|
||||
PublishLegalPageDto,
|
||||
RevertToVersionDto,
|
||||
LegalPageListFilter,
|
||||
} from './legal-pages.dto';
|
||||
|
||||
@Controller('api/v1/admin/legal-pages')
|
||||
@UseGuards(AdminGuard)
|
||||
export class LegalPagesController {
|
||||
constructor(private readonly legalPagesService: LegalPagesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() filter: LegalPageListFilter) {
|
||||
const pages = await this.legalPagesService.findAll(filter);
|
||||
return pages.map(page => this.legalPagesService.toResponse(page));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const page = await this.legalPagesService.findOne(id);
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateLegalPageDto, @Request() req) {
|
||||
const userId = req.user?.id;
|
||||
const page = await this.legalPagesService.create(dto, userId);
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateLegalPageDto, @Request() req) {
|
||||
const userId = req.user?.id;
|
||||
const page = await this.legalPagesService.update(id, dto, userId);
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
async publish(@Param('id') id: string, @Body() dto: PublishLegalPageDto, @Request() req) {
|
||||
const userId = req.user?.id;
|
||||
const page = await this.legalPagesService.publish(id, dto.isPublished, userId);
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Param('id') id: string) {
|
||||
await this.legalPagesService.delete(id);
|
||||
}
|
||||
|
||||
@Get(':id/versions')
|
||||
async getVersionHistory(@Param('id') id: string) {
|
||||
const versions = await this.legalPagesService.getVersionHistory(id);
|
||||
return versions.map(version => this.legalPagesService.toVersionResponse(version));
|
||||
}
|
||||
|
||||
@Get(':id/versions/:version')
|
||||
async getVersion(@Param('id') id: string, @Param('version') version: number) {
|
||||
const versionData = await this.legalPagesService.getVersionById(id, version);
|
||||
return this.legalPagesService.toVersionResponse(versionData);
|
||||
}
|
||||
|
||||
@Post(':id/revert')
|
||||
async revertToVersion(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RevertToVersionDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
const page = await this.legalPagesService.revertToVersion(id, dto.version, userId);
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoint for reading published legal pages
|
||||
@Controller('api/v1/legal-pages')
|
||||
export class PublicLegalPagesController {
|
||||
constructor(private readonly legalPagesService: LegalPagesService) {}
|
||||
|
||||
@Public()
|
||||
@Get(':slug')
|
||||
async getPublishedPage(
|
||||
@Param('slug') slug: string,
|
||||
@Query('language') language: string = 'en',
|
||||
) {
|
||||
const page = await this.legalPagesService.findBySlugAndLanguage(slug, language);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
error: 'Page not found',
|
||||
message: `Legal page "${slug}" is not available in language "${language}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.legalPagesService.toResponse(page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { IsString, IsBoolean, IsOptional, IsIn, MinLength, MaxLength, IsInt } from 'class-validator';
|
||||
|
||||
export class CreateLegalPageDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
content: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['en', 'es', 'fr', 'pt', 'zh'])
|
||||
@IsOptional()
|
||||
language?: string = 'en';
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean = false;
|
||||
}
|
||||
|
||||
export class UpdateLegalPageDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@IsOptional()
|
||||
content?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
export class PublishLegalPageDto {
|
||||
@IsBoolean()
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export class RevertToVersionDto {
|
||||
@IsInt()
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface LegalPageListFilter {
|
||||
slug?: string;
|
||||
language?: string;
|
||||
isPublished?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface LegalPageResponse {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
language: string;
|
||||
version: number;
|
||||
isPublished: boolean;
|
||||
lastUpdatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface LegalPageVersionResponse {
|
||||
id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LegalPage } from '../../../database/entities/legal-page.entity';
|
||||
import { LegalPageVersion } from '../../../database/entities/legal-page-version.entity';
|
||||
import { LegalPagesService } from './legal-pages.service';
|
||||
import { LegalPagesController, PublicLegalPagesController } from './legal-pages.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LegalPage, LegalPageVersion])],
|
||||
controllers: [LegalPagesController, PublicLegalPagesController],
|
||||
providers: [LegalPagesService],
|
||||
exports: [LegalPagesService],
|
||||
})
|
||||
export class LegalPagesModule {}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { LegalPage } from '../../../database/entities/legal-page.entity';
|
||||
import { LegalPageVersion } from '../../../database/entities/legal-page-version.entity';
|
||||
import {
|
||||
CreateLegalPageDto,
|
||||
UpdateLegalPageDto,
|
||||
LegalPageListFilter,
|
||||
LegalPageResponse,
|
||||
LegalPageVersionResponse,
|
||||
} from './legal-pages.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LegalPagesService {
|
||||
constructor(
|
||||
@InjectRepository(LegalPage)
|
||||
private readonly legalPageRepository: Repository<LegalPage>,
|
||||
@InjectRepository(LegalPageVersion)
|
||||
private readonly legalPageVersionRepository: Repository<LegalPageVersion>,
|
||||
) {}
|
||||
|
||||
async findAll(filter: LegalPageListFilter = {}): Promise<LegalPage[]> {
|
||||
const where: any = {};
|
||||
|
||||
if (filter.slug) {
|
||||
where.slug = filter.slug;
|
||||
}
|
||||
|
||||
if (filter.language) {
|
||||
where.language = filter.language;
|
||||
}
|
||||
|
||||
if (filter.isPublished !== undefined) {
|
||||
where.isPublished = filter.isPublished;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
where.title = ILike(`%${filter.search}%`);
|
||||
}
|
||||
|
||||
return this.legalPageRepository.find({
|
||||
where,
|
||||
relations: ['lastUpdatedByUser'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<LegalPage> {
|
||||
const page = await this.legalPageRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['lastUpdatedByUser', 'versions', 'versions.createdByUser'],
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Legal page with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
async findBySlugAndLanguage(slug: string, language: string = 'en'): Promise<LegalPage | null> {
|
||||
return this.legalPageRepository.findOne({
|
||||
where: { slug, language, isPublished: true },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateLegalPageDto, userId: string): Promise<LegalPage> {
|
||||
// Check if slug+language already exists
|
||||
const existing = await this.legalPageRepository.findOne({
|
||||
where: { slug: dto.slug, language: dto.language || 'en' },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Legal page with slug "${dto.slug}" and language "${dto.language || 'en'}" already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = this.legalPageRepository.create({
|
||||
...dto,
|
||||
version: 1,
|
||||
lastUpdatedBy: userId,
|
||||
});
|
||||
|
||||
const savedPage = await this.legalPageRepository.save(page);
|
||||
|
||||
// Create initial version
|
||||
await this.createVersion(savedPage.id, savedPage.title, savedPage.content, userId, 1);
|
||||
|
||||
return this.findOne(savedPage.id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateLegalPageDto, userId: string): Promise<LegalPage> {
|
||||
const page = await this.findOne(id);
|
||||
|
||||
// Increment version if content changed
|
||||
let newVersion = page.version;
|
||||
if (dto.content && dto.content !== page.content) {
|
||||
newVersion = page.version + 1;
|
||||
|
||||
// Create version history
|
||||
await this.createVersion(
|
||||
page.id,
|
||||
dto.title || page.title,
|
||||
dto.content,
|
||||
userId,
|
||||
newVersion,
|
||||
);
|
||||
}
|
||||
|
||||
// Update page
|
||||
Object.assign(page, {
|
||||
...dto,
|
||||
version: newVersion,
|
||||
lastUpdatedBy: userId,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await this.legalPageRepository.save(page);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async publish(id: string, isPublished: boolean, userId: string): Promise<LegalPage> {
|
||||
const page = await this.findOne(id);
|
||||
|
||||
page.isPublished = isPublished;
|
||||
page.lastUpdatedBy = userId;
|
||||
page.updatedAt = new Date();
|
||||
|
||||
await this.legalPageRepository.save(page);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const page = await this.findOne(id);
|
||||
await this.legalPageRepository.remove(page);
|
||||
}
|
||||
|
||||
async getVersionHistory(pageId: string): Promise<LegalPageVersion[]> {
|
||||
const page = await this.findOne(pageId);
|
||||
|
||||
return this.legalPageVersionRepository.find({
|
||||
where: { legalPageId: page.id },
|
||||
relations: ['createdByUser'],
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getVersionById(pageId: string, versionNumber: number): Promise<LegalPageVersion> {
|
||||
const version = await this.legalPageVersionRepository.findOne({
|
||||
where: { legalPageId: pageId, version: versionNumber },
|
||||
relations: ['createdByUser'],
|
||||
});
|
||||
|
||||
if (!version) {
|
||||
throw new NotFoundException(
|
||||
`Version ${versionNumber} not found for legal page ${pageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
async revertToVersion(pageId: string, versionNumber: number, userId: string): Promise<LegalPage> {
|
||||
const page = await this.findOne(pageId);
|
||||
const targetVersion = await this.getVersionById(pageId, versionNumber);
|
||||
|
||||
// Create a new version with reverted content
|
||||
const newVersion = page.version + 1;
|
||||
await this.createVersion(
|
||||
page.id,
|
||||
targetVersion.title,
|
||||
targetVersion.content,
|
||||
userId,
|
||||
newVersion,
|
||||
);
|
||||
|
||||
// Update page with reverted content
|
||||
page.title = targetVersion.title;
|
||||
page.content = targetVersion.content;
|
||||
page.version = newVersion;
|
||||
page.lastUpdatedBy = userId;
|
||||
page.updatedAt = new Date();
|
||||
|
||||
await this.legalPageRepository.save(page);
|
||||
|
||||
return this.findOne(pageId);
|
||||
}
|
||||
|
||||
private async createVersion(
|
||||
pageId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
userId: string,
|
||||
versionNumber: number,
|
||||
): Promise<LegalPageVersion> {
|
||||
const version = this.legalPageVersionRepository.create({
|
||||
legalPageId: pageId,
|
||||
version: versionNumber,
|
||||
title,
|
||||
content,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return this.legalPageVersionRepository.save(version);
|
||||
}
|
||||
|
||||
// Helper method to transform entity to response
|
||||
toResponse(page: LegalPage): LegalPageResponse {
|
||||
return {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
language: page.language,
|
||||
version: page.version,
|
||||
isPublished: page.isPublished,
|
||||
lastUpdatedBy: page.lastUpdatedBy,
|
||||
createdAt: page.createdAt,
|
||||
updatedAt: page.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
toVersionResponse(version: LegalPageVersion): LegalPageVersionResponse {
|
||||
return {
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
createdBy: version.createdBy,
|
||||
createdAt: version.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user