Skip to content
Closed
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: 4 additions & 2 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ CLOUDINARY_SECRET=
CLOUDINARY_CLOUD_NAME=exygy
# app secret
APP_SECRET="some-long-secret-key"
# url for the proxy
# url for the proxy
PROXY_URL=
# the node env the app should be running as
NODE_ENV=development
# how long a generated multi-factor authentication code should be
MFA_CODE_LENGTH=5
# TTL for the mfa code, stored in milliseconds
# TTL for the mfa code, stored in milliseconds
MFA_CODE_VALID=60000
# how long logins are locked after too many failed login attempts in milliseconds
AUTH_LOCK_LOGIN_COOLDOWN=1800000
Expand Down Expand Up @@ -94,3 +94,5 @@ S3_BUCKET=
S3_ACCESS_TOKEN=
# Secret key for the service account to the s3 bucket
S3_SECRET_TOKEN=
FAST_API_KEY=
FAST_API_URL=
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable

ALTER TABLE "user_accounts" ADD COLUMN "ai_consent_given_at" TIMESTAMPTZ(6),
ADD COLUMN "has_consented_to_ai" BOOLEAN DEFAULT false;
2 changes: 2 additions & 0 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,8 @@ model UserAccounts {
failedLoginAttemptsCount Int @default(0) @map("failed_login_attempts_count")
phoneNumberVerified Boolean? @default(false) @map("phone_number_verified")
agreedToTermsOfService Boolean @default(false) @map("agreed_to_terms_of_service")
hasConsentedToAI Boolean? @default(false) @map("has_consented_to_ai")
aiConsentGivenAt DateTime? @map("ai_consent_given_at") @db.Timestamptz(6)
hitConfirmationUrl DateTime? @map("hit_confirmation_url") @db.Timestamptz(6)
activeAccessToken String? @map("active_access_token") @db.VarChar
activeRefreshToken String? @map("active_refresh_token") @db.VarChar
Expand Down
66 changes: 66 additions & 0 deletions api/src/controllers/data-explorer.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
Controller,
Get,
Post,
Body,
Query,
Request,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request as ExpressRequest } from 'express';
import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options';
import { ValidationsGroupsEnum } from '../enums/shared/validation-groups-enum';
import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor';
import { PermissionTypeDecorator } from '../decorators/permission-type.decorator';
import { ApiKeyGuard } from '../guards/api-key.guard';
import { JwtAuthGuard } from '../guards/jwt.guard';
import { DataExplorerService } from '../services/data-explorer.service';
import { DataExplorerParams } from '../dtos/applications/data-explorer/params/data-explorer-params.dto';
import { DataExplorerReport } from '../dtos/applications/data-explorer/products/data-explorer-report.dto';
import { GenerateInsightParams } from '../dtos/applications/data-explorer/generate-insight-params.dto';
import { GenerateInsightResponse } from '../dtos/applications/data-explorer/generate-insight-response.dto';

@Controller('data-explorer')
@ApiTags('data-explorer')
@UsePipes(
new ValidationPipe({
...defaultValidationPipeOptions,
groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.partners],
}),
)
@UseGuards(ApiKeyGuard, JwtAuthGuard)
@PermissionTypeDecorator('application')
@UseInterceptors(ActivityLogInterceptor)
export class DataExplorerController {
constructor(private readonly dataExplorerService: DataExplorerService) {}

@Get('generate-report')
@ApiOperation({
summary: 'Generate a report',
operationId: 'generateReport',
})
@ApiOkResponse({ type: DataExplorerReport })
async generateReport(
@Request() req: ExpressRequest,
@Query() queryParams: DataExplorerParams,
) {
return await this.dataExplorerService.generateReport(queryParams, req);
}

@Post('generate-insight')
@ApiOperation({
summary: 'Generate AI insights from data',
operationId: 'generateInsight',
})
@ApiOkResponse({ type: GenerateInsightResponse })
async generateInsight(
@Request() req: ExpressRequest,
@Body() body: GenerateInsightParams,
) {
return await this.dataExplorerService.generateInsight(body, req);
}
}
17 changes: 17 additions & 0 deletions api/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction
import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor';
import { PermissionTypeDecorator } from '../decorators/permission-type.decorator';
import { UserFilterParams } from '../dtos/users/user-filter-params.dto';
import { UserAiConsentDto } from '../dtos/users/user-ai-consent.dto';
import { UserCsvExporterService } from '../services/user-csv-export.service';
import { ExportLogInterceptor } from '../interceptors/export-log.interceptor';
import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto';
Expand Down Expand Up @@ -125,6 +126,22 @@ export class UserController {
return await this.userService.favoriteListings(userId);
}

@Put('ai-consent')
@ApiOperation({
summary: 'Update AI consent preference',
operationId: 'updateAiConsent',
})
@ApiOkResponse({ type: User })
@UseGuards(JwtAuthGuard)
@UseInterceptors(ActivityLogInterceptor)
async updateAIConsent(
@Request() req: ExpressRequest,
@Body() body: UserAiConsentDto,
): Promise<User> {
const user = mapTo(User, req['user']);
return await this.userService.updateAIConsent(user.id, body.hasConsented);
}

@Post()
@ApiOperation({
summary: 'Creates a public only user',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Expose, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsObject, ValidateNested } from 'class-validator';
import { ValidationsGroupsEnum } from '../../../enums/shared/validation-groups-enum';
import { ReportProducts } from './products/data-explorer-report-products.dto';

export class GenerateInsightParams {
@Expose()
@ApiProperty({
type: ReportProducts,
description: 'The current data object containing report products',
})
@IsObject({ groups: [ValidationsGroupsEnum.default] })
@ValidateNested({ groups: [ValidationsGroupsEnum.default] })
@Type(() => ReportProducts)
data: ReportProducts;

@Expose()
@ApiProperty({
type: String,
example: 'What are the key trends in this data?',
description: 'The prompt to send to the AI for generating insights',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
prompt: string;

@Expose()
@ApiPropertyOptional({
type: String,
example: 'jurisdictionId',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
jurisdictionId?: string;

@Expose()
@ApiPropertyOptional({
type: String,
example: 'userId',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
userId?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Expose } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { ValidationsGroupsEnum } from '../../../enums/shared/validation-groups-enum';
import { AbstractDTO } from '../../shared/abstract.dto';

export class GenerateInsightResponse extends AbstractDTO {
@Expose()
@ApiProperty({
type: String,
example: 'The data shows significant trends...',
description: 'Markdown-formatted AI-generated insights',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
insight: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Expose, Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
IsNumber,
IsArray,
Min,
Max,
} from 'class-validator';
import { ValidationsGroupsEnum } from '../../../../enums/shared/validation-groups-enum';

export class DataExplorerParams {
@Expose()
@ApiPropertyOptional({
type: String,
example: 'jurisdictionId',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsOptional()
jurisdictionId?: string;

@Expose()
@ApiPropertyOptional({
type: String,
example: 'userId',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsOptional()
userId?: string;

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['1', '2', '3', '4+'],
description: 'Filter by household size categories',
})
@IsArray()
@IsOptional()
householdSize?: string[];

@Expose()
@ApiPropertyOptional({
type: Number,
example: 30000,
description: 'Minimum household income in USD',
})
@IsNumber()
@Type(() => Number)
@Min(0)
@IsOptional()
minIncome?: number;

@Expose()
@ApiPropertyOptional({
type: Number,
example: 100000,
description: 'Maximum household income in USD',
})
@IsNumber()
@Type(() => Number)
@Min(0)
@IsOptional()
maxIncome?: number;

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['0-30% AMI', '31-50% AMI'],
description: 'Area Median Income level categories',
})
@IsArray()
@IsOptional()
amiLevels?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['yes', 'no'],
description: 'Housing voucher or subsidy status',
})
@IsArray()
@IsOptional()
voucherStatuses?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['mobility', 'hearing'],
description: 'Accessibility accommodation types',
})
@IsArray()
@IsOptional()
accessibilityTypes?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['Asian', 'White'],
description: 'Racial categories for filtering',
})
@IsArray()
@IsOptional()
races?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['Hispanic or Latino', 'Not Hispanic or Latino'],
description: 'Ethnicity categories for filtering',
})
@IsArray()
@IsOptional()
ethnicities?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['San Francisco County', 'Alameda County'],
description: 'Counties where applicants currently reside',
})
@IsArray()
@IsOptional()
applicantResidentialCounties?: string[];

@Expose()
@ApiPropertyOptional({
type: [String],
example: ['San Francisco County', 'Alameda County'],
description: 'Counties where applicants work',
})
@IsArray()
@IsOptional()
applicantWorkCounties?: string[];

@Expose()
@ApiPropertyOptional({
type: Number,
example: 18,
description: 'Minimum age of applicant',
})
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(150)
@IsOptional()
minAge?: number;

@Expose()
@ApiPropertyOptional({
type: Number,
example: 65,
description: 'Maximum age of applicant',
})
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(150)
@IsOptional()
maxAge?: number;

@Expose()
@ApiPropertyOptional({
type: String,
example: '2025-01-01',
description: 'Start date for filtering applications (ISO 8601 format)',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsOptional()
startDate?: string;

@Expose()
@ApiPropertyOptional({
type: String,
example: '2025-06-30',
description: 'End date for filtering applications (ISO 8601 format)',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsOptional()
endDate?: string;
}
Loading
Loading