Skip to content

Commit dd07e93

Browse files
committed
feat(xpeng): App-Email als Pflichtfeld, editierbar, AutoSync für alle frei
- XPeng App-Email ist jetzt Pflichtfeld beim Einrichten (war optional) mit klarem Hinweistext: wird von XPeng zur Identitätsverifikation benötigt - Email wird im Outbound-Mail-Body mitgeschickt, nicht nur als CC - Neuer PATCH /connections/{id}/email Endpoint + UI in XpengConnectionsList: bestehende Connections können Email nachträglich setzen/korrigieren (Pencil-Icon -> inline Input -> Enter/Check zum Speichern) - AutoSync-Gate entfernt: war auf Premium/Beta beschränkt, jetzt für alle User (Backend: canUseXpengAutoSync()-Check + UserRepository aus Service entfernt; Frontend: canActivateTelemetry-Bedingung aus ImportsView entfernt)
1 parent 0ef7762 commit dd07e93

13 files changed

Lines changed: 408 additions & 53 deletions

File tree

backend/src/main/java/com/evmonitor/application/imports/xpeng/XpengConnectionService.java

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
import com.evmonitor.domain.Car;
44
import com.evmonitor.domain.CarRepository;
5-
import com.evmonitor.domain.User;
6-
import com.evmonitor.domain.UserRepository;
75
import com.evmonitor.domain.xpeng.VinUtils;
86
import com.evmonitor.infrastructure.persistence.xpeng.XpengConnection;
97
import com.evmonitor.infrastructure.persistence.xpeng.XpengConnectionRepository;
@@ -27,7 +25,6 @@ public class XpengConnectionService {
2725

2826
private final XpengConnectionRepository connectionRepo;
2927
private final CarRepository carRepository;
30-
private final UserRepository userRepository;
3128

3229
@Transactional
3330
public XpengConnection grantConsent(UUID userId, UUID carId, String vin,
@@ -41,13 +38,6 @@ public XpengConnection grantConsent(UUID userId, UUID carId, String vin,
4138
if (vin == null || vin.length() != 17) {
4239
throw new IllegalArgumentException("VIN muss 17 Zeichen lang sein");
4340
}
44-
if (autoSync) {
45-
User user = userRepository.findById(userId)
46-
.orElseThrow(() -> new IllegalArgumentException("User nicht gefunden"));
47-
if (!user.canUseXpengAutoSync()) {
48-
throw new SecurityException("AutoSync erfordert ein AutoSync-Abo oder eine privilegierte Rolle");
49-
}
50-
}
5141
if (xpengEmail != null && !xpengEmail.isBlank()) {
5242
if (xpengEmail.length() > 255 || !EMAIL_PATTERN.matcher(xpengEmail).matches()) {
5343
throw new IllegalArgumentException("Ungültige XPeng-E-Mail-Adresse");
@@ -115,11 +105,6 @@ public XpengConnection activateAutoSync(UUID userId, UUID connectionId, String x
115105
if (!conn.isActive()) {
116106
throw new IllegalArgumentException("Verbindung ist nicht aktiv");
117107
}
118-
User user = userRepository.findById(userId)
119-
.orElseThrow(() -> new IllegalArgumentException("User nicht gefunden"));
120-
if (!user.canUseXpengAutoSync()) {
121-
throw new SecurityException("AutoSync erfordert ein AutoSync-Abo oder eine privilegierte Rolle");
122-
}
123108
if (xpengEmail != null && !xpengEmail.isBlank()) {
124109
if (xpengEmail.length() > 255 || !EMAIL_PATTERN.matcher(xpengEmail).matches()) {
125110
throw new IllegalArgumentException("Ungültige XPeng-E-Mail-Adresse");
@@ -136,6 +121,21 @@ public XpengConnection activateAutoSync(UUID userId, UUID connectionId, String x
136121
return saved;
137122
}
138123

124+
@Transactional
125+
public XpengConnection updateXpengEmail(UUID userId, UUID connectionId, String email) {
126+
XpengConnection conn = connectionRepo.findById(connectionId)
127+
.orElseThrow(() -> new IllegalArgumentException("Verbindung nicht gefunden"));
128+
if (!conn.getUserId().equals(userId)) {
129+
throw new SecurityException("Diese Verbindung gehört dir nicht");
130+
}
131+
if (email == null || email.isBlank() || email.length() > 255 || !EMAIL_PATTERN.matcher(email.strip()).matches()) {
132+
throw new IllegalArgumentException("Ungültige XPeng-E-Mail-Adresse");
133+
}
134+
conn.setXpengEmail(email.strip());
135+
log.info("XpengConnection: xpengEmail updated user={} connection={}", userId, connectionId);
136+
return connectionRepo.save(conn);
137+
}
138+
139139
@Transactional
140140
public void revokeConsent(UUID userId, UUID connectionId) {
141141
XpengConnection conn = connectionRepo.findById(connectionId)

backend/src/main/java/com/evmonitor/application/imports/xpeng/XpengOutboundMailService.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public void sendDataRequest(XpengConnection conn) {
5151
helper.setReplyTo(replyToInbox);
5252
helper.setCc(ccAddress);
5353
helper.setSubject(subject);
54-
helper.setText(buildBody(conn.getVin()), false);
54+
helper.setText(buildBody(conn.getVin(), ccAddress), false);
5555
mailSender.send(msg);
5656
} catch (Exception e) {
5757
throw new RuntimeException("XPeng DA-Anfrage konnte nicht gesendet werden: " + e.getMessage(), e);
@@ -68,7 +68,7 @@ static String buildSubject(XpengConnection conn) {
6868
+ " (EU Data Act) " + TOKEN_PREFIX + conn.getRoutingToken() + TOKEN_SUFFIX;
6969
}
7070

71-
private static String buildBody(String vin) {
71+
static String buildBody(String vin, String xpengAppEmail) {
7272
return """
7373
Dear XPENG Data Protection Team,
7474
@@ -77,6 +77,7 @@ private static String buildBody(String vin) {
7777
for the most recent 30-day period available.
7878
7979
VIN: %s
80+
XPeng App account email: %s
8081
8182
Please provide the data in the Excel format used for similar requests. \
8283
If the file is encrypted, kindly send the password in a separate email \
@@ -87,6 +88,6 @@ private static String buildBody(String vin) {
8788
Art. 5 EU Data Act, based on explicit user consent.
8889
8990
Thank you and best regards,
90-
EV Monitor - https://ev-monitor.net""".formatted(vin);
91+
EV Monitor - https://ev-monitor.net""".formatted(vin, xpengAppEmail);
9192
}
9293
}

backend/src/main/java/com/evmonitor/infrastructure/web/XpengConnectionController.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ public ResponseEntity<?> activateAutoSync(@AuthenticationPrincipal UserPrincipal
8080
}
8181
}
8282

83+
@PatchMapping("/{connectionId}/email")
84+
public ResponseEntity<?> updateEmail(@AuthenticationPrincipal UserPrincipal principal,
85+
@PathVariable UUID connectionId,
86+
@RequestBody EmailRequest req) {
87+
try {
88+
XpengConnection conn = service.updateXpengEmail(
89+
principal.getUser().getId(), connectionId, req.xpengEmail());
90+
return ResponseEntity.ok(toDto(conn));
91+
} catch (SecurityException e) {
92+
return ResponseEntity.status(403).body(Map.of("error", e.getMessage()));
93+
} catch (IllegalArgumentException e) {
94+
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
95+
}
96+
}
97+
8398
@DeleteMapping("/{connectionId}")
8499
public ResponseEntity<?> revoke(@AuthenticationPrincipal UserPrincipal principal,
85100
@PathVariable UUID connectionId) {
@@ -102,6 +117,9 @@ private ConnectionDto toDto(XpengConnection c) {
102117
maskEmail(c.getXpengEmail()));
103118
}
104119

120+
public record EmailRequest(
121+
@NotNull @Email @Size(max = 255) String xpengEmail) {}
122+
105123
public record AutoSyncRequest(
106124
@NotNull Boolean consentAccepted,
107125
@Email @Size(max = 255) String xpengEmail) {}

backend/src/test/java/com/evmonitor/application/imports/xpeng/XpengConnectionServiceTest.java

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
import com.evmonitor.domain.CarBrand;
55
import com.evmonitor.domain.CarRepository;
66
import com.evmonitor.domain.CarStatus;
7-
import com.evmonitor.domain.AuthProvider;
8-
import com.evmonitor.domain.SubscriptionTier;
9-
import com.evmonitor.domain.User;
10-
import com.evmonitor.domain.UserRepository;
117
import com.evmonitor.infrastructure.persistence.xpeng.XpengConnection;
128
import com.evmonitor.infrastructure.persistence.xpeng.XpengConnectionRepository;
139
import org.junit.jupiter.api.Test;
@@ -30,7 +26,6 @@ class XpengConnectionServiceTest {
3026

3127
@Mock CarRepository carRepository;
3228
@Mock XpengConnectionRepository connectionRepo;
33-
@Mock UserRepository userRepository;
3429
@InjectMocks XpengConnectionService service;
3530

3631
private static final UUID USER = UUID.randomUUID();
@@ -124,7 +119,6 @@ void revokeSetsTimestamp() {
124119
@Test
125120
void autoSyncGrantSetsConsentV2AndEmail() {
126121
when(carRepository.findById(CAR)).thenReturn(Optional.of(ownedBy(USER)));
127-
when(userRepository.findById(USER)).thenReturn(Optional.of(userWithTier(SubscriptionTier.AUTOSYNC)));
128122
when(connectionRepo.findByCarId(CAR)).thenReturn(Optional.empty());
129123
when(connectionRepo.save(any())).thenAnswer(inv -> inv.getArgument(0));
130124

@@ -137,24 +131,53 @@ void autoSyncGrantSetsConsentV2AndEmail() {
137131
}
138132

139133
@Test
140-
void autoSyncGrantFailsForFreeUser() {
134+
void autoSyncGrantSucceedsForFreeUser() {
141135
when(carRepository.findById(CAR)).thenReturn(Optional.of(ownedBy(USER)));
142-
when(userRepository.findById(USER)).thenReturn(Optional.of(userWithTier(SubscriptionTier.NONE)));
136+
when(connectionRepo.findByCarId(CAR)).thenReturn(Optional.empty());
137+
when(connectionRepo.save(any())).thenAnswer(i -> i.getArgument(0));
143138

144-
assertThrows(SecurityException.class,
145-
() -> service.grantConsent(USER, CAR, VALID_VIN, "1.1.1.1", "ua", true, null));
146-
verify(connectionRepo, never()).save(any());
139+
XpengConnection conn = service.grantConsent(USER, CAR, VALID_VIN, "1.1.1.1", "ua", true, "user@xpeng.com");
140+
141+
assertTrue(conn.isAutoSyncEnabled());
142+
verify(connectionRepo).save(any());
147143
}
148144

149145
@Test
150146
void rejectsInvalidXpengEmail() {
151147
when(carRepository.findById(CAR)).thenReturn(Optional.of(ownedBy(USER)));
152-
when(userRepository.findById(USER)).thenReturn(Optional.of(userWithTier(SubscriptionTier.AUTOSYNC)));
153148

154149
assertThrows(IllegalArgumentException.class,
155150
() -> service.grantConsent(USER, CAR, VALID_VIN, "1.1.1.1", "ua", true, "not-an-email"));
156151
}
157152

153+
@Test
154+
void updateXpengEmailSavesEmailForOwner() {
155+
UUID connId = UUID.randomUUID();
156+
XpengConnection conn = activeConnection(connId, USER);
157+
when(connectionRepo.findById(connId)).thenReturn(Optional.of(conn));
158+
when(connectionRepo.save(any())).thenAnswer(i -> i.getArgument(0));
159+
160+
XpengConnection result = service.updateXpengEmail(USER, connId, "new@xpeng.com");
161+
162+
assertEquals("new@xpeng.com", result.getXpengEmail());
163+
}
164+
165+
@Test
166+
void updateXpengEmailRejectsNonOwner() {
167+
UUID connId = UUID.randomUUID();
168+
when(connectionRepo.findById(connId)).thenReturn(Optional.of(activeConnection(connId, OTHER_USER)));
169+
170+
assertThrows(SecurityException.class, () -> service.updateXpengEmail(USER, connId, "x@xpeng.com"));
171+
}
172+
173+
@Test
174+
void updateXpengEmailRejectsInvalidFormat() {
175+
UUID connId = UUID.randomUUID();
176+
when(connectionRepo.findById(connId)).thenReturn(Optional.of(activeConnection(connId, USER)));
177+
178+
assertThrows(IllegalArgumentException.class, () -> service.updateXpengEmail(USER, connId, "not-an-email"));
179+
}
180+
158181
@Test
159182
void getConnectionsDueForAutoRequestDelegatesToRepository() {
160183
XpengConnection due = XpengConnection.builder()
@@ -169,6 +192,13 @@ void getConnectionsDueForAutoRequestDelegatesToRepository() {
169192
verify(connectionRepo).findDueForAutoRequest(any());
170193
}
171194

195+
private XpengConnection activeConnection(UUID connId, UUID owner) {
196+
return XpengConnection.builder()
197+
.id(connId).userId(owner).carId(CAR).vin(VALID_VIN)
198+
.autoSyncEnabled(true).consentVersion(XpengConnection.AUTOSYNC_CONSENT_VERSION)
199+
.consentGrantedAt(LocalDateTime.now()).build();
200+
}
201+
172202
private Car ownedBy(UUID owner) {
173203
return Car.builder()
174204
.id(CAR).userId(owner)
@@ -178,12 +208,4 @@ private Car ownedBy(UUID owner) {
178208
.build();
179209
}
180210

181-
private User userWithTier(SubscriptionTier tier) {
182-
return User.builder()
183-
.id(USER).email("user@test.com").username("testuser")
184-
.authProvider(AuthProvider.LOCAL)
185-
.role("USER").premium(tier.isPaid()).subscriptionTier(tier)
186-
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now())
187-
.build();
188-
}
189211
}

frontend/src/api/xpengService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export const xpengService = {
5454
return resp.data
5555
},
5656

57+
async updateEmail(connectionId: string, xpengEmail: string): Promise<XpengConnectionDto> {
58+
const resp = await api.patch(`/imports/xpeng/connections/${connectionId}/email`, { xpengEmail })
59+
return resp.data
60+
},
61+
5762
async revoke(connectionId: string): Promise<void> {
5863
await api.delete(`/imports/xpeng/connections/${connectionId}`)
5964
},

frontend/src/components/imports/XpengAutoSyncImport.vue

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,14 @@ function closeUpgrade() {
7070
7171
async function submitUpgrade(connectionId: string) {
7272
if (!upgradeAccepted.value) return
73+
if (!upgradeEmail.value.trim()) {
74+
localError.value = t('xpeng.autosync_email_required')
75+
return
76+
}
7377
upgradeBusy.value = true
7478
localError.value = ''
7579
try {
76-
const email = upgradeEmail.value.trim() || undefined
77-
await xpengService.activateAutoSync(connectionId, email)
80+
await xpengService.activateAutoSync(connectionId, upgradeEmail.value.trim())
7881
closeUpgrade()
7982
await refresh()
8083
} catch (e: unknown) {
@@ -187,7 +190,7 @@ onMounted(refresh)
187190
<div class="flex gap-2">
188191
<button
189192
@click="submitUpgrade(c.id)"
190-
:disabled="!upgradeAccepted || upgradeBusy"
193+
:disabled="!upgradeAccepted || !upgradeEmail.trim() || upgradeBusy"
191194
class="btn-3d inline-flex items-center gap-1.5 bg-amber-500 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed text-gray-950 font-bold uppercase tracking-wide text-xs px-4 py-2 rounded-sm">
192195
<BoltIcon class="w-3.5 h-3.5" />
193196
{{ upgradeBusy ? t('common.loading') : t('xpeng.autosync_upgrade_btn') }}

frontend/src/components/imports/XpengConnectionsList.vue

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue'
2+
import { computed, ref } from 'vue'
33
import { useI18n } from 'vue-i18n'
4-
import { TrashIcon, BoltIcon } from '@heroicons/vue/24/outline'
4+
import { TrashIcon, BoltIcon, PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/vue/24/outline'
55
import xpengService, { type XpengConnectionDto, type XpengJobDto } from '../../api/xpengService'
66
77
const { t } = useI18n()
@@ -23,6 +23,35 @@ const visibleConnections = computed(() => {
2323
return props.connections
2424
})
2525
26+
const editingEmailId = ref<string | null>(null)
27+
const editEmailDraft = ref('')
28+
const emailBusy = ref(false)
29+
30+
function openEmailEdit(c: XpengConnectionDto) {
31+
editingEmailId.value = c.id
32+
editEmailDraft.value = ''
33+
}
34+
35+
function closeEmailEdit() {
36+
editingEmailId.value = null
37+
editEmailDraft.value = ''
38+
}
39+
40+
async function saveEmail(connectionId: string) {
41+
if (!editEmailDraft.value.trim()) return
42+
emailBusy.value = true
43+
try {
44+
await xpengService.updateEmail(connectionId, editEmailDraft.value.trim())
45+
closeEmailEdit()
46+
emit('refresh')
47+
} catch (e: unknown) {
48+
const err = e as { response?: { data?: { error?: string } }; message?: string }
49+
emit('error', err.response?.data?.error ?? err.message ?? 'Unknown error')
50+
} finally {
51+
emailBusy.value = false
52+
}
53+
}
54+
2655
function statusLabel(status: string): string {
2756
return t(`xpeng.status_${status.toLowerCase()}`)
2857
}
@@ -79,6 +108,35 @@ async function revoke(connectionId: string) {
79108
<div v-if="c.autoSyncEnabled && c.lastRequestSentAt" class="font-mono">
80109
{{ t('xpeng.autosync_last_request', { date: formatDate(c.lastRequestSentAt) }) }}
81110
</div>
111+
<!-- Email Zeile -->
112+
<div v-if="editingEmailId !== c.id" class="flex items-center gap-1.5 pt-0.5">
113+
<span :class="c.xpengEmailMasked ? 'text-gray-500 dark:text-gray-400' : 'text-amber-600 dark:text-amber-400 font-medium'">
114+
{{ c.xpengEmailMasked ?? t('xpeng.email_missing') }}
115+
</span>
116+
<button @click="openEmailEdit(c)"
117+
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
118+
:title="t('xpeng.email_edit_btn')">
119+
<PencilIcon class="w-3 h-3" />
120+
</button>
121+
</div>
122+
<div v-else class="flex items-center gap-1.5 pt-1">
123+
<input
124+
v-model="editEmailDraft"
125+
type="email"
126+
autofocus
127+
:placeholder="t('xpeng.autosync_email_placeholder')"
128+
class="flex-1 px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-sm bg-white dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
129+
@keyup.enter="saveEmail(c.id)"
130+
@keyup.escape="closeEmailEdit"
131+
/>
132+
<button @click="saveEmail(c.id)" :disabled="emailBusy || !editEmailDraft.trim()"
133+
class="text-green-600 hover:text-green-700 disabled:opacity-40">
134+
<CheckIcon class="w-4 h-4" />
135+
</button>
136+
<button @click="closeEmailEdit" class="text-gray-400 hover:text-gray-600">
137+
<XMarkIcon class="w-4 h-4" />
138+
</button>
139+
</div>
82140
</div>
83141
</div>
84142
<div class="ml-3">

frontend/src/components/imports/XpengConsentStep.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ async function submit() {
5050
emit('error', t('xpeng.err_vin_length'))
5151
return
5252
}
53+
if (isAutoSync.value && !xpengEmail.value.trim()) {
54+
emit('error', t('xpeng.autosync_email_required'))
55+
return
56+
}
5357
busy.value = true
5458
try {
55-
const email = isAutoSync.value && xpengEmail.value.trim() ? xpengEmail.value.trim() : undefined
59+
const email = isAutoSync.value ? xpengEmail.value.trim() : undefined
5660
await xpengService.grantConsent(carId.value, normalized, isAutoSync.value, email)
5761
vin.value = ''
5862
accepted.value = false

0 commit comments

Comments
 (0)