Skip to content

Commit 7c7b422

Browse files
authored
Merge pull request #282 from DanielCharis1/fix-issues-260-259-258-270
Fix issues #260, #259, #258, #270: Implement SEO metadata, soft delet…
2 parents d06e788 + 6b6efa3 commit 7c7b422

13 files changed

Lines changed: 546 additions & 10 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- CreateTable
2+
ALTER TABLE "properties" ADD COLUMN "deleted_at" TIMESTAMP(3),
3+
ADD COLUMN "is_deleted" BOOLEAN NOT NULL DEFAULT false,
4+
ADD COLUMN "meta_title" TEXT,
5+
ADD COLUMN "meta_description" TEXT,
6+
ADD COLUMN "meta_keywords" TEXT[];
7+
8+
-- CreateIndex
9+
CREATE INDEX "properties_is_deleted_idx" ON "properties"("is_deleted");
10+
11+
-- CreateIndex
12+
CREATE INDEX "properties_deleted_at_idx" ON "properties"("deleted_at");

prisma/schema.prisma

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ model Property {
110110
createdAt DateTime @default(now()) @map("created_at")
111111
updatedAt DateTime @updatedAt @map("updated_at")
112112
113+
// Soft delete fields for issues #258 and #259
114+
deletedAt DateTime? @map("deleted_at")
115+
isDeleted Boolean @default(false) @map("is_deleted")
116+
113117
// Valuation fields
114118
estimatedValue Decimal? @map("estimated_value")
115119
valuationDate DateTime? @map("valuation_date")
@@ -136,6 +140,11 @@ model Property {
136140
137141
rejectionReason String? @map("rejection_reason")
138142
143+
// SEO metadata fields for issue #260
144+
metaTitle String? @map("meta_title")
145+
metaDescription String? @map("meta_description")
146+
metaKeywords String[] @map("meta_keywords")
147+
139148
// Relationships
140149
reports PropertyReport[]
141150
versions PropertyVersion[]
@@ -151,6 +160,8 @@ model Property {
151160
@@index([status, createdAt(sort: Desc)]) // For property listings by status and date
152161
@@index([ownerId, createdAt(sort: Desc)]) // For owner's properties by date
153162
@@index([status, price]) // For property search by status and price
163+
@@index([isDeleted]) // For filtering soft-deleted properties
164+
@@index([deletedAt]) // For cleanup of old soft-deleted properties
154165
@@unique([ownerId, title, location]) // Prevent duplicate properties for same owner with same title and location
155166
@@unique([latitude, longitude, ownerId]) // Prevent duplicate properties at same coordinates for same owner
156167
@@map("properties")

src/auth/auth.module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { LocalStrategy } from './strategies/local.strategy';
99
import { Web3Strategy } from './strategies/web3.strategy';
1010
import { JwtAuthGuard } from './guards/jwt-auth.guard';
1111
import { LoginAttemptsGuard } from './guards/login-attempts.guard';
12+
import { WalletSignatureService } from './wallet-signature.service';
13+
import { WalletSignatureGuard } from './guards/wallet-signature.guard';
1214
import { MfaModule } from './mfa/mfa.module';
1315
import { UsersModule } from '../users/users.module';
1416

@@ -35,6 +37,7 @@ import { UsersModule } from '../users/users.module';
3537
JwtStrategy,
3638
LocalStrategy,
3739
Web3Strategy,
40+
WalletSignatureService,
3841
{
3942
provide: 'JwtAuthGuard',
4043
useClass: JwtAuthGuard,
@@ -43,9 +46,13 @@ import { UsersModule } from '../users/users.module';
4346
provide: 'LoginAttemptsGuard',
4447
useClass: LoginAttemptsGuard,
4548
},
49+
{
50+
provide: 'WalletSignatureGuard',
51+
useClass: WalletSignatureGuard,
52+
},
4653
// NOTE: Removed UserService here because it's now imported via UsersModule
4754
// NOTE: Removed RedisService as it's now globally provided by LoggingModule
4855
],
49-
exports: [AuthService],
56+
exports: [AuthService, WalletSignatureService],
5057
})
5158
export class AuthModule {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
2+
import { Reflector } from '@nestjs/core';
3+
import { WalletSignatureService } from './wallet-signature.service';
4+
5+
/**
6+
* Wallet Signature Guard
7+
*
8+
* Protects endpoints by requiring valid wallet signature verification.
9+
* This guard addresses issue #270 by ensuring wallet ownership is verified.
10+
*/
11+
@Injectable()
12+
export class WalletSignatureGuard {
13+
constructor(
14+
private readonly walletSignatureService: WalletSignatureService,
15+
private readonly reflector: Reflector,
16+
) {}
17+
18+
async canActivate(context: ExecutionContext): Promise<boolean> {
19+
const request = context.switchToHttp().getRequest();
20+
const user = request.user;
21+
22+
if (!user) {
23+
throw new UnauthorizedException('User not authenticated');
24+
}
25+
26+
// Check if user has a wallet address
27+
if (!user.walletAddress) {
28+
throw new UnauthorizedException('User must have a wallet address for this operation');
29+
}
30+
31+
// Verify wallet ownership using signature
32+
try {
33+
this.walletSignatureService.verifyWalletOwnership(request, user.walletAddress);
34+
return true;
35+
} catch (error) {
36+
throw new UnauthorizedException('Invalid wallet signature');
37+
}
38+
}
39+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
2+
import { Request } from 'express';
3+
import * as StellarSdk from 'stellar-sdk';
4+
5+
/**
6+
* Wallet Signature Verification Service
7+
*
8+
* Handles verification of Stellar wallet signatures to authenticate users.
9+
* This service addresses issue #270 by ensuring that wallet address ownership
10+
* is verified before allowing sensitive operations like email updates.
11+
*/
12+
@Injectable()
13+
export class WalletSignatureService {
14+
private readonly logger = new Logger(WalletSignatureService.name);
15+
16+
/**
17+
* Verify Stellar wallet signature
18+
*
19+
* @param signature - The signature from x-signature header
20+
* @param walletAddress - The wallet address claiming ownership
21+
* @param message - The message that was signed (default: "Login to SoroSusu")
22+
* @returns {boolean} True if signature is valid
23+
* @throws {UnauthorizedException} If signature is invalid
24+
*/
25+
verifySignature(signature: string, walletAddress: string, message: string = 'Login to SoroSusu'): boolean {
26+
try {
27+
if (!signature) {
28+
throw new UnauthorizedException('Signature is required');
29+
}
30+
31+
if (!walletAddress) {
32+
throw new UnauthorizedException('Wallet address is required');
33+
}
34+
35+
// Create a keypair from the wallet address
36+
const keypair = StellarSdk.Keypair.fromPublicKey(walletAddress);
37+
38+
// Verify the signature
39+
const isValid = keypair.verify(message, signature);
40+
41+
if (!isValid) {
42+
this.logger.warn(`Invalid signature for wallet ${walletAddress}`);
43+
throw new UnauthorizedException('Invalid signature');
44+
}
45+
46+
this.logger.log(`Signature verified successfully for wallet ${walletAddress}`);
47+
return true;
48+
} catch (error) {
49+
if (error instanceof UnauthorizedException) {
50+
throw error;
51+
}
52+
53+
this.logger.error('Signature verification failed', error);
54+
throw new UnauthorizedException('Signature verification failed');
55+
}
56+
}
57+
58+
/**
59+
* Extract signature from request headers
60+
*
61+
* @param request - Express request object
62+
* @returns {string} The signature from x-signature header
63+
* @throws {UnauthorizedException} If signature header is missing
64+
*/
65+
extractSignature(request: Request): string {
66+
const signature = request.headers['x-signature'] as string;
67+
68+
if (!signature) {
69+
this.logger.warn('Missing x-signature header in request');
70+
throw new UnauthorizedException('x-signature header is required');
71+
}
72+
73+
return signature;
74+
}
75+
76+
/**
77+
* Verify wallet ownership for user update operations
78+
*
79+
* @param request - Express request object
80+
* @param userWalletAddress - The user's stored wallet address
81+
* @returns {boolean} True if ownership is verified
82+
* @throws {UnauthorizedException} If ownership cannot be verified
83+
*/
84+
verifyWalletOwnership(request: Request, userWalletAddress: string): boolean {
85+
try {
86+
const signature = this.extractSignature(request);
87+
88+
if (!userWalletAddress) {
89+
throw new UnauthorizedException('User does not have a wallet address');
90+
}
91+
92+
return this.verifySignature(signature, userWalletAddress);
93+
} catch (error) {
94+
this.logger.error('Wallet ownership verification failed', error);
95+
throw error;
96+
}
97+
}
98+
99+
/**
100+
* Generate message for signing
101+
*
102+
* @param customMessage - Optional custom message
103+
* @returns {string} Message to be signed by user
104+
*/
105+
generateSigningMessage(customMessage?: string): string {
106+
return customMessage || 'Login to SoroSusu';
107+
}
108+
109+
/**
110+
* Validate Stellar address format
111+
*
112+
* @param address - The Stellar address to validate
113+
* @returns {boolean} True if address is valid
114+
*/
115+
isValidStellarAddress(address: string): boolean {
116+
try {
117+
StellarSdk.StrKey.decodeEd25519PublicKey(address);
118+
return true;
119+
} catch (error) {
120+
return false;
121+
}
122+
}
123+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { PrismaService } from '../database/prisma/prisma.service';
4+
5+
/**
6+
* Property Cleanup Service
7+
*
8+
* Handles scheduled cleanup tasks for soft-deleted properties.
9+
* This service addresses issue #258 by permanently deleting old soft-deleted properties.
10+
*/
11+
@Injectable()
12+
export class PropertyCleanupService {
13+
private readonly logger = new Logger(PropertyCleanupService.name);
14+
15+
constructor(private readonly prisma: PrismaService) {}
16+
17+
/**
18+
* Cleanup old soft-deleted properties
19+
*
20+
* Runs daily at 2:00 AM to permanently delete properties that have been soft deleted
21+
* for more than 30 days. This addresses issue #258.
22+
*/
23+
@Cron('0 2 * * *') // Daily at 2:00 AM
24+
async cleanupOldSoftDeletedProperties(): Promise<void> {
25+
this.logger.log('Starting cleanup of old soft-deleted properties');
26+
27+
try {
28+
// Delete properties that have been soft deleted for more than 30 days
29+
const thirtyDaysAgo = new Date();
30+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
31+
32+
const result = await (this.prisma as any).property.deleteMany({
33+
where: {
34+
isDeleted: true,
35+
deletedAt: {
36+
lt: thirtyDaysAgo,
37+
},
38+
},
39+
});
40+
41+
this.logger.log(`Cleaned up ${result.count} old soft-deleted properties`);
42+
} catch (error) {
43+
this.logger.error('Failed to cleanup old soft-deleted properties', error);
44+
throw error;
45+
}
46+
}
47+
48+
/**
49+
* Manual cleanup trigger for testing/admin purposes
50+
*
51+
* @param daysOld - Number of days old to delete (default: 30)
52+
*/
53+
async manualCleanup(daysOld: number = 30): Promise<{ deletedCount: number }> {
54+
this.logger.log(`Starting manual cleanup of properties older than ${daysOld} days`);
55+
56+
try {
57+
const cutoffDate = new Date();
58+
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
59+
60+
const result = await (this.prisma as any).property.deleteMany({
61+
where: {
62+
isDeleted: true,
63+
deletedAt: {
64+
lt: cutoffDate,
65+
},
66+
},
67+
});
68+
69+
this.logger.log(`Manual cleanup completed: ${result.count} properties deleted`);
70+
return { deletedCount: result.count };
71+
} catch (error) {
72+
this.logger.error('Manual cleanup failed', error);
73+
throw error;
74+
}
75+
}
76+
77+
/**
78+
* Get statistics about soft-deleted properties
79+
*/
80+
async getSoftDeletedStats(): Promise<{
81+
totalSoftDeleted: number;
82+
olderThan30Days: number;
83+
olderThan60Days: number;
84+
oldestDeletion: Date | null;
85+
}> {
86+
try {
87+
const now = new Date();
88+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
89+
const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000);
90+
91+
const [
92+
totalSoftDeleted,
93+
olderThan30Days,
94+
olderThan60Days,
95+
oldestProperty,
96+
] = await Promise.all([
97+
(this.prisma as any).property.count({
98+
where: { isDeleted: true },
99+
}),
100+
(this.prisma as any).property.count({
101+
where: {
102+
isDeleted: true,
103+
deletedAt: { lt: thirtyDaysAgo },
104+
},
105+
}),
106+
(this.prisma as any).property.count({
107+
where: {
108+
isDeleted: true,
109+
deletedAt: { lt: sixtyDaysAgo },
110+
},
111+
}),
112+
(this.prisma as any).property.findFirst({
113+
where: { isDeleted: true },
114+
orderBy: { deletedAt: 'asc' },
115+
select: { deletedAt: true },
116+
}),
117+
]);
118+
119+
return {
120+
totalSoftDeleted,
121+
olderThan30Days,
122+
olderThan60Days,
123+
oldestDeletion: oldestProperty?.deletedAt || null,
124+
};
125+
} catch (error) {
126+
this.logger.error('Failed to get soft-deleted stats', error);
127+
throw error;
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)